[LUCI Analysis] Migrate code from infra/appengine/weetbix/.

Code has been taken from infra repo as at bdc58f75b708a91, with
the following changes:
- Package imports have been updated.
- Monorail protos had to be copied to
  go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto
  as protos are currently not available inside the LUCI go repo.
  A script has been created to make this process repeatable via
  go generate in said directory.
- Participle library added to go.mod and go.sum as this
  dependency did not exist in the LUCI repository currently.
- Copyright headers have been updated.

Various updates to project readmes and documentation will be
required to complete the rename, but that will made made in a
subsequent CL.

BUG=b:243488110
TEST=INTEGRATION_TESTS=1 go test go.chromium.org/luci/analysis/...
TEST=npm run build in frontend/ui/

Change-Id: I1e151ac92942fb494af0f606157bb11b4169be04
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/luci-go/+/3852455
Auto-Submit: Patrick Meiring <meiring@google.com>
Reviewed-by: Matthew Warton <mwarton@google.com>
Reviewed-by: Chan Li <chanli@chromium.org>
Commit-Queue: Patrick Meiring <meiring@google.com>
diff --git a/analysis/.gcloudignore b/analysis/.gcloudignore
new file mode 100644
index 0000000..5561f73
--- /dev/null
+++ b/analysis/.gcloudignore
@@ -0,0 +1,12 @@
+# This file specifies files that are *not* uploaded to Google Cloud Platform
+# using gcloud. It follows the same syntax as .gitignore, with the addition of
+# "#!include" directives (which insert the entries of the given .gitignore-style
+# file at that point).
+#
+# For more information, run:
+#   $ gcloud topic gcloudignore
+#
+
+.gcloudignore
+.gitignore
+node_modules
diff --git a/analysis/README.md b/analysis/README.md
index 5dbd9d3..c1c9268 100644
--- a/analysis/README.md
+++ b/analysis/README.md
@@ -1,3 +1,126 @@
-## LUCI Analysis
+# Weetbix
 
-Directory for LUCI Analysis (formerly Weetbix).
+Weetbix is a system designed to understand and reduce the impact of test
+failures.
+
+This app follows the structure described in [Template for GAE Standard app].
+
+## Local Development
+
+To run the server locally, first authorize as the correct GCP project (you should only need to do this once):
+```
+gcloud config set project chops-weetbix-dev
+gcloud auth application-default login
+```
+
+Authenticate in LUCI and in :
+
+1. In Weetbix's `frontend` directory run:
+    ```
+    luci-auth login -scopes "https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/userinfo.email"
+    ```
+2. In the same directory run:
+   ```
+   cipd auth-login
+   ```
+
+Once the GCP project is authorized, in one terminal start esbuild to rebuild the UI code after any changes:
+```
+cd frontend/ui
+npm run watch
+```
+
+To run the server, in another terminal use:
+```
+cd frontend
+go run main.go \
+ -cloud-project chops-weetbix-dev \
+ -spanner-database projects/chops-weetbix-dev/instances/dev/databases/chops-weetbix-dev \
+ -auth-service-host chrome-infra-auth-dev.appspot.com \
+ -default-request-timeout 10m0s \
+ -config-local-dir ../configs
+```
+
+`-default-request-timeout` is needed if exercising cron jobs through the admin
+portal as cron jobs run through the /admin/ endpoint attract the default
+timeout of 1 minute, instead of the 10 minute timeout of the /internal/ endpoint
+(hit by GAE cron jobs when they are actually executing).
+
+Note that `-config-local-dir` is required only if you plan on modifying config
+and loading it into Cloud Datastore via the read-config cron job accessible via
+http://127.0.0.1:8900/admin/portal/cron for testing. Omitting this, the server
+will fetch the current config from Cloud Datastore (as periodically refreshed
+from LUCI Config Service).
+
+You may also be able to use an arbitrary cloud project (e.g. 'dev') if you
+setup Cloud Datastore emulator and setup a config for that project under
+configs.
+
+You can run the UI tests by:
+```
+cd frontend/ui
+npm run test
+```
+
+You can debug the UI unit tests by visiting the server started by the following command with your browser:
+```
+cd frontend/ui
+npm run test-watch
+```
+
+## Deployment
+
+### Developer Testing {#test-deployment}
+
+Weetbix uses `gae.py` for deployment of the GAE instances for developer
+testing (e.g. of local changes).
+
+First, enter the infra env (via the infra.git checkout):
+```
+eval infra/go/env.py
+```
+
+Then use the following commands to deploy:
+```
+cd frontend/ui
+npm run build
+gae.py upload --target-version ${USER} -A chops-weetbix-dev
+```
+
+### Dev and Prod Instances
+
+The dev and prod instances are managed via
+[LUCI GAE Automatic Deployment (Googlers-only)](http://go/luci/how_to_deploy.md).
+
+## Run Spanner integration tests using Cloud Spanner Emulator
+
+### Install Cloud Spanner Emulator
+
+#### Linux
+
+The Cloud Spanner Emulator is part of the bundled gcloud, to make sure it's installed:
+
+```
+cd infra
+gclient runhooks
+eval `./go/env.py`
+which gcloud # should show bundled gcloud
+gcloud components list # should see cloud-spanner-emulator is installed
+```
+
+### Run tests
+
+From command line, first set environment variables:
+
+```
+export INTEGRATION_TESTS=1
+```
+
+Then run go test as usual. For example
+
+```
+go test -v ./...
+```
+
+[Template for GAE Standard app]: https://chromium.googlesource.com/infra/luci/luci-go/+/HEAD/examples/appengine/helloworld_standard/README.md
+
diff --git a/analysis/app/buildbucket.go b/analysis/app/buildbucket.go
new file mode 100644
index 0000000..bc062c5
--- /dev/null
+++ b/analysis/app/buildbucket.go
@@ -0,0 +1,154 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"strings"
+
+	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/server/router"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+	ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+)
+
+const (
+	// userAgentTagKey is the key of the user agent tag.
+	userAgentTagKey = "user_agent"
+	// userAgentCQ is the value of the user agent tag, for builds started
+	// by LUCI CV.
+	userAgentCQ = "cq"
+)
+
+var (
+	buildCounter = metric.NewCounter(
+		"weetbix/ingestion/pubsub/buildbucket_builds",
+		"The number of buildbucket builds received by Weetbix from PubSub.",
+		nil,
+		// The LUCI Project.
+		field.String("project"),
+		// "success", "ignored", "transient-failure" or "permanent-failure".
+		field.String("status"))
+)
+
+// BuildbucketPubSubHandler accepts and process buildbucket Pub/Sub messages.
+// As of Aug 2021, Weetbix subscribes to this Pub/Sub topic to get completed
+// Chromium CI builds.
+// For CQ builds, Weetbix uses CV Pub/Sub as the entrypoint.
+func BuildbucketPubSubHandler(ctx *router.Context) {
+	project := "unknown"
+	status := "unknown"
+	defer func() {
+		// Closure for late binding.
+		buildCounter.Add(ctx.Context, 1, project, status)
+	}()
+
+	project, processed, err := bbPubSubHandlerImpl(ctx.Context, ctx.Request)
+	if err != nil {
+		errors.Log(ctx.Context, errors.Annotate(err, "handling buildbucket pubsub event").Err())
+		status = processErr(ctx, err)
+		return
+	}
+	if processed {
+		status = "success"
+		// Use subtly different "success" response codes to surface in
+		// standard GAE logs whether an ingestion was ignored or not,
+		// while still acknowledging the pub/sub.
+		// See https://cloud.google.com/pubsub/docs/push#receiving_messages.
+		ctx.Writer.WriteHeader(http.StatusOK)
+	} else {
+		status = "ignored"
+		ctx.Writer.WriteHeader(http.StatusNoContent) // 204
+	}
+}
+
+func bbPubSubHandlerImpl(ctx context.Context, request *http.Request) (project string, processed bool, err error) {
+	msg, err := parseBBMessage(ctx, request)
+	if err != nil {
+		return "unknown", false, errors.Annotate(err, "failed to parse buildbucket pub/sub message").Err()
+	}
+	processed, err = processBBMessage(ctx, msg)
+	if err != nil {
+		return msg.Build.Project, false, errors.Annotate(err, "processing build").Err()
+	}
+	return msg.Build.Project, processed, nil
+}
+
+type buildBucketMessage struct {
+	Build    bbv1.LegacyApiCommonBuildMessage
+	Hostname string
+}
+
+func parseBBMessage(ctx context.Context, r *http.Request) (*buildBucketMessage, error) {
+	var psMsg pubsubMessage
+	if err := json.NewDecoder(r.Body).Decode(&psMsg); err != nil {
+		return nil, errors.Annotate(err, "could not decode buildbucket pubsub message").Err()
+	}
+
+	var bbMsg buildBucketMessage
+	if err := json.Unmarshal(psMsg.Message.Data, &bbMsg); err != nil {
+		return nil, errors.Annotate(err, "could not parse buildbucket pubsub message data").Err()
+	}
+	return &bbMsg, nil
+}
+
+func processBBMessage(ctx context.Context, message *buildBucketMessage) (processed bool, err error) {
+	if message.Build.Status != bbv1.StatusCompleted {
+		// Received build that hasn't completed yet, ignore it.
+		return false, nil
+	}
+	if message.Build.CreatedTs == 0 {
+		return false, errors.New("build did not have created timestamp specified")
+	}
+
+	userAgents := extractTagValues(message.Build.Tags, userAgentTagKey)
+	isPresubmit := len(userAgents) == 1 && userAgents[0] == userAgentCQ
+
+	project := message.Build.Project
+	id := control.BuildID(message.Hostname, message.Build.Id)
+	result := &ctlpb.BuildResult{
+		CreationTime: timestamppb.New(bbv1.ParseTimestamp(message.Build.CreatedTs)),
+		Id:           message.Build.Id,
+		Host:         message.Hostname,
+		Project:      project,
+	}
+
+	if err := JoinBuildResult(ctx, id, project, isPresubmit, result); err != nil {
+		return false, errors.Annotate(err, "joining build result").Err()
+	}
+	return true, nil
+}
+
+func extractTagValues(tags []string, key string) []string {
+	var values []string
+	for _, tag := range tags {
+		tagParts := strings.SplitN(tag, ":", 2)
+		if len(tagParts) < 2 {
+			// Invalid tag.
+			continue
+		}
+		if tagParts[0] == key {
+			values = append(values, tagParts[1])
+		}
+	}
+	return values
+}
diff --git a/analysis/app/buildbucket_test.go b/analysis/app/buildbucket_test.go
new file mode 100644
index 0000000..b400a3b
--- /dev/null
+++ b/analysis/app/buildbucket_test.go
@@ -0,0 +1,188 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"encoding/json"
+	"io"
+	"net/http"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
+	. "go.chromium.org/luci/common/testing/assertions"
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+	"go.chromium.org/luci/server/tq"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/cv"
+	controlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	_ "go.chromium.org/luci/analysis/internal/services/resultingester" // Needed to ensure task class is registered.
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestHandleBuild(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx, skdr := tq.TestingContext(ctx, nil)
+
+		Convey(`Test BuildbucketPubSubHandler`, func() {
+			Convey(`CI build is processed`, func() {
+				// Buildbucket timestamps are only in microsecond precision.
+				t := time.Now().Truncate(time.Nanosecond * 1000)
+
+				buildExp := bbv1.LegacyApiCommonBuildMessage{
+					Project:   "buildproject",
+					Bucket:    "luci.buildproject.bucket",
+					Id:        87654321,
+					Status:    bbv1.StatusCompleted,
+					CreatedTs: bbv1.FormatTimestamp(t),
+				}
+
+				test := func() {
+					r := &http.Request{Body: makeBBReq(buildExp, "bb-hostname")}
+					project, processed, err := bbPubSubHandlerImpl(ctx, r)
+					So(err, ShouldBeNil)
+					So(processed, ShouldBeTrue)
+					So(project, ShouldEqual, "buildproject")
+
+					So(len(skdr.Tasks().Payloads()), ShouldEqual, 1)
+					resultsTask := skdr.Tasks().Payloads()[0].(*taskspb.IngestTestResults)
+					So(resultsTask, ShouldResembleProto, &taskspb.IngestTestResults{
+						PartitionTime: timestamppb.New(t),
+						Build: &controlpb.BuildResult{
+							Host:         "bb-hostname",
+							Id:           87654321,
+							CreationTime: timestamppb.New(t),
+							Project:      "buildproject",
+						},
+					})
+				}
+				Convey(`Standard CI Build`, func() {
+					test()
+
+					// Test repeated processing does not lead to further
+					// ingestion tasks.
+					test()
+				})
+				Convey(`Unusual CI Build`, func() {
+					// v8 project had some buildbucket-triggered builds
+					// with both the user_agent:cq and user_agent:recipe tags.
+					// These should be treated as CI builds.
+					buildExp.Tags = []string{"user_agent:cq", "user_agent:recipe"}
+					test()
+				})
+			})
+
+			Convey(`Try build is processed`, func() {
+				t := time.Date(2025, time.April, 1, 2, 3, 4, 0, time.UTC)
+
+				buildExp := bbv1.LegacyApiCommonBuildMessage{
+					Project:   "buildproject",
+					Bucket:    "luci.buildproject.bucket",
+					Id:        14141414,
+					Status:    bbv1.StatusCompleted,
+					CreatedTs: bbv1.FormatTimestamp(t),
+					Tags:      []string{"user_agent:cq"},
+				}
+
+				Convey(`With presubmit run processed previously`, func() {
+					partitionTime := time.Now()
+					run := &cvv0.Run{
+						Id:         "projects/cvproject/runs/123e4567-e89b-12d3-a456-426614174000",
+						Mode:       "FULL_RUN",
+						Status:     cvv0.Run_FAILED,
+						Owner:      "chromium-autoroll@skia-public.iam.gserviceaccount.com",
+						CreateTime: timestamppb.New(partitionTime),
+						Tryjobs: []*cvv0.Tryjob{
+							tryjob(2),
+							tryjob(14141414),
+						},
+						Cls: []*cvv0.GerritChange{
+							{
+								Host:     "chromium-review.googlesource.com",
+								Change:   12345,
+								Patchset: 1,
+							},
+						},
+					}
+					runs := map[string]*cvv0.Run{
+						run.Id: run,
+					}
+					ctx = cv.UseFakeClient(ctx, runs)
+
+					// Process presubmit run.
+					r := &http.Request{Body: makeCVChromiumRunReq(run.Id)}
+					project, processed, err := cvPubSubHandlerImpl(ctx, r)
+					So(err, ShouldBeNil)
+					So(processed, ShouldBeTrue)
+					So(project, ShouldEqual, "cvproject")
+
+					So(len(skdr.Tasks().Payloads()), ShouldEqual, 0)
+
+					// Process build.
+					r = &http.Request{Body: makeBBReq(buildExp, bbHost)}
+					project, processed, err = bbPubSubHandlerImpl(ctx, r)
+					So(err, ShouldBeNil)
+					So(processed, ShouldBeTrue)
+					So(project, ShouldEqual, "buildproject")
+
+					So(len(skdr.Tasks().Payloads()), ShouldEqual, 1)
+					task := skdr.Tasks().Payloads()[0].(*taskspb.IngestTestResults)
+					So(task, ShouldResembleProto, &taskspb.IngestTestResults{
+						PartitionTime: timestamppb.New(partitionTime),
+						Build: &controlpb.BuildResult{
+							Host:         bbHost,
+							Id:           14141414,
+							CreationTime: timestamppb.New(t),
+							Project:      "buildproject",
+						},
+						PresubmitRun: &controlpb.PresubmitResult{
+							PresubmitRunId: &pb.PresubmitRunId{
+								System: "luci-cv",
+								Id:     "cvproject/123e4567-e89b-12d3-a456-426614174000",
+							},
+							Status:       pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED,
+							Owner:        "automation",
+							Mode:         pb.PresubmitRunMode_FULL_RUN,
+							CreationTime: timestamppb.New(partitionTime),
+							Critical:     true,
+						},
+					})
+				})
+				Convey(`Without presubmit run processed previously`, func() {
+					r := &http.Request{Body: makeBBReq(buildExp, bbHost)}
+					project, processed, err := bbPubSubHandlerImpl(ctx, r)
+					So(err, ShouldBeNil)
+					So(processed, ShouldBeTrue)
+					So(project, ShouldEqual, "buildproject")
+					So(len(skdr.Tasks().Payloads()), ShouldEqual, 0)
+				})
+			})
+		})
+	})
+}
+
+func makeBBReq(build bbv1.LegacyApiCommonBuildMessage, hostname string) io.ReadCloser {
+	bmsg := struct {
+		Build    bbv1.LegacyApiCommonBuildMessage `json:"build"`
+		Hostname string                           `json:"hostname"`
+	}{build, hostname}
+	bm, _ := json.Marshal(bmsg)
+	return makeReq(bm)
+}
diff --git a/analysis/app/commitverifier.go b/analysis/app/commitverifier.go
new file mode 100644
index 0000000..26ce369
--- /dev/null
+++ b/analysis/app/commitverifier.go
@@ -0,0 +1,230 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"regexp"
+	"sort"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+	cvv1 "go.chromium.org/luci/cv/api/v1"
+	"go.chromium.org/luci/server/router"
+	"google.golang.org/protobuf/encoding/protojson"
+
+	"go.chromium.org/luci/analysis/internal/cv"
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+	ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const (
+	// TODO(chanli@@) Removing the hosts after CVPubSub and GetRun RPC added them.
+	// Host name of buildbucket.
+	bbHost = "cr-buildbucket.appspot.com"
+)
+
+var (
+	cvRunCounter = metric.NewCounter(
+		"weetbix/ingestion/pubsub/cv_runs",
+		"The number of CV runs received by Weetbix from PubSub.",
+		nil,
+		// The LUCI Project.
+		field.String("project"),
+		// "success", "transient-failure", "permanent-failure" or "ignored".
+		field.String("status"))
+
+	runIDRe = regexp.MustCompile(`^projects/(.*)/runs/(.*)$`)
+
+	// Automation service accounts.
+	automationAccountRE = regexp.MustCompile(`^.*@.*\.gserviceaccount\.com$`)
+)
+
+// CVRunPubSubHandler accepts and processes CV Pub/Sub messages.
+func CVRunPubSubHandler(ctx *router.Context) {
+	status := "unknown"
+	project := "unknown"
+	defer func() {
+		// Closure for late binding.
+		cvRunCounter.Add(ctx.Context, 1, project, status)
+	}()
+	project, processed, err := cvPubSubHandlerImpl(ctx.Context, ctx.Request)
+
+	switch {
+	case err != nil:
+		errors.Log(ctx.Context, errors.Annotate(err, "handling cv pubsub event").Err())
+		status = processErr(ctx, err)
+		return
+	case !processed:
+		status = "ignored"
+		// Use subtly different "success" response codes to surface in
+		// standard GAE logs whether an ingestion was ignored or not,
+		// while still acknowledging the pub/sub.
+		// See https://cloud.google.com/pubsub/docs/push#receiving_messages.
+		ctx.Writer.WriteHeader(http.StatusNoContent) // 204
+	default:
+		status = "success"
+		ctx.Writer.WriteHeader(http.StatusOK)
+	}
+}
+
+func cvPubSubHandlerImpl(ctx context.Context, request *http.Request) (project string, processed bool, err error) {
+	psRun, err := extractPubSubRun(request)
+	if err != nil {
+		return "unknown", false, errors.Annotate(err, "failed to extract run").Err()
+	}
+
+	project, runID, err := parseRunID(psRun.Id)
+	if err != nil {
+		return "unknown", false, errors.Annotate(err, "failed to parse run ID").Err()
+	}
+
+	// We do not check if the project is configured in Weetbix,
+	// as CV runs can include projects from other projects.
+	// It is the projects of these builds that the data
+	// gets ingested into.
+
+	run, err := getRun(ctx, psRun)
+	switch {
+	case err != nil:
+		return project, false, errors.Annotate(err, "failed to get run").Err()
+	case run.GetCreateTime() == nil:
+		return project, false, errors.New("could not get create time for the run")
+	}
+
+	owner := "user"
+	if automationAccountRE.MatchString(run.Owner) {
+		owner = "automation"
+	}
+
+	mode, err := pbutil.PresubmitRunModeFromString(run.Mode)
+	if err != nil {
+		return project, false, errors.Annotate(err, "failed to parse run mode").Err()
+	}
+
+	presubmitResultByBuildID := make(map[string]*ctlpb.PresubmitResult)
+	for _, tj := range run.Tryjobs {
+		b := tj.GetResult().GetBuildbucket()
+		if b == nil {
+			// Non build-bucket result.
+			continue
+		}
+
+		if tj.Reuse {
+			// Do not ingest re-used tryjobs.
+			// Builds should be ingested with the CV run that initiated
+			// them, not a CV run that re-used them.
+			// Tryjobs can also be marked re-used if they were user
+			// initiated through gerrit. In this case, the build would
+			// not have been tagged as being part of a CV run (e.g.
+			// through user_agent: cq), so it will not expect to be
+			// joined to a CV run.
+			continue
+		}
+
+		buildID := control.BuildID(bbHost, b.Id)
+		if _, ok := presubmitResultByBuildID[buildID]; ok {
+			logging.Warningf(ctx, "CV Run %s has build %s as tryjob multiple times, ignoring the second occurances", psRun.Id, buildID)
+			continue
+		}
+
+		status, err := pbutil.PresubmitRunStatusFromLUCICV(run.Status)
+		if err != nil {
+			return project, false, errors.Annotate(err, "failed to parse run status").Err()
+		}
+
+		presubmitResultByBuildID[buildID] = &ctlpb.PresubmitResult{
+			PresubmitRunId: &pb.PresubmitRunId{
+				System: "luci-cv",
+				Id:     fmt.Sprintf("%s/%s", project, runID),
+			},
+			Status:       status,
+			Mode:         mode,
+			Owner:        owner,
+			CreationTime: run.CreateTime,
+			Critical:     tj.Critical,
+		}
+	}
+
+	if err := JoinPresubmitResult(ctx, presubmitResultByBuildID, project); err != nil {
+		return project, true, errors.Annotate(err, "joining presubmit results").Err()
+	}
+
+	return project, true, nil
+}
+
+func sortChangelists(cls []*pb.Changelist) {
+	less := func(i, j int) bool {
+		if cls[i].Host < cls[j].Host {
+			return true
+		}
+		if cls[i].Host == cls[j].Host &&
+			cls[i].Change < cls[j].Change {
+			return true
+		}
+		if cls[i].Host == cls[j].Host &&
+			cls[i].Change == cls[j].Change &&
+			cls[i].Patchset < cls[j].Patchset {
+			return true
+		}
+		return false
+	}
+	sort.Slice(cls, less)
+}
+
+func extractPubSubRun(r *http.Request) (*cvv1.PubSubRun, error) {
+	var msg pubsubMessage
+	if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
+		return nil, errors.Annotate(err, "could not decode cv pubsub message").Err()
+	}
+
+	var run cvv1.PubSubRun
+	err := protojson.Unmarshal(msg.Message.Data, &run)
+	if err != nil {
+		return nil, errors.Annotate(err, "could not parse cv pubsub message data").Err()
+	}
+	return &run, nil
+}
+
+func parseRunID(runID string) (project string, run string, err error) {
+	m := runIDRe.FindStringSubmatch(runID)
+	if m == nil {
+		return "", "", errors.Reason("run ID does not match %s", runIDRe).Err()
+	}
+	return m[1], m[2], nil
+}
+
+// getRun gets the full Run message by make a GetRun RPC to CV.
+//
+// Currently we're calling cv.v0.Runs.GetRun, and should switch to v1 when it's
+// ready to use.
+func getRun(ctx context.Context, psRun *cvv1.PubSubRun) (*cvv0.Run, error) {
+	c, err := cv.NewClient(ctx, psRun.Hostname)
+	if err != nil {
+		return nil, errors.Annotate(err, "failed to create cv client").Err()
+	}
+	req := &cvv0.GetRunRequest{
+		Id: psRun.Id,
+	}
+	return c.GetRun(ctx, req)
+}
diff --git a/analysis/app/commitverifier_test.go b/analysis/app/commitverifier_test.go
new file mode 100644
index 0000000..3ea9e82
--- /dev/null
+++ b/analysis/app/commitverifier_test.go
@@ -0,0 +1,259 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"fmt"
+	"io"
+	"net/http"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	bbv1 "go.chromium.org/luci/common/api/buildbucket/buildbucket/v1"
+	"go.chromium.org/luci/common/clock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+	cvv1 "go.chromium.org/luci/cv/api/v1"
+	"go.chromium.org/luci/server/tq"
+	"google.golang.org/protobuf/encoding/protojson"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/cv"
+	controlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	_ "go.chromium.org/luci/analysis/internal/services/resultingester" // Needed to ensure task class is registered.
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// bbCreateTime is the create time assigned to buildbucket builds, for testing.
+// Must be in microsecond precision as that is the precision of buildbucket.
+var bbCreateTime = time.Date(2025, time.December, 1, 2, 3, 4, 5000, time.UTC)
+
+func TestHandleCVRun(t *testing.T) {
+	Convey(`Test CVRunPubSubHandler`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx, skdr := tq.TestingContext(ctx, nil)
+
+		// Setup two ingested tryjob builds.
+		buildIDs := []int64{87654321, 87654322}
+		for _, buildID := range buildIDs {
+			buildExp := bbv1.LegacyApiCommonBuildMessage{
+				Project:   "buildproject",
+				Bucket:    "luci.buildproject.bucket",
+				Id:        buildID,
+				Status:    bbv1.StatusCompleted,
+				CreatedTs: bbv1.FormatTimestamp(bbCreateTime),
+				Tags:      []string{"user_agent:cq"},
+			}
+			r := &http.Request{Body: makeBBReq(buildExp, bbHost)}
+			project, processed, err := bbPubSubHandlerImpl(ctx, r)
+			So(err, ShouldBeNil)
+			So(processed, ShouldBeTrue)
+			So(project, ShouldEqual, "buildproject")
+		}
+		So(len(skdr.Tasks().Payloads()), ShouldEqual, 0)
+
+		Convey(`CV run is processed`, func() {
+			ctx, skdr := tq.TestingContext(ctx, nil)
+			rID := "id_full_run"
+			fullRunID := fullRunID("cvproject", rID)
+
+			processCVRun := func(run *cvv0.Run) (processed bool, tasks []*taskspb.IngestTestResults) {
+				existingTaskCount := len(skdr.Tasks().Payloads())
+
+				runs := map[string]*cvv0.Run{
+					fullRunID: run,
+				}
+				ctx = cv.UseFakeClient(ctx, runs)
+				r := &http.Request{Body: makeCVChromiumRunReq(fullRunID)}
+				project, processed, err := cvPubSubHandlerImpl(ctx, r)
+				So(err, ShouldBeNil)
+				So(project, ShouldEqual, "cvproject")
+
+				tasks = make([]*taskspb.IngestTestResults, 0,
+					len(skdr.Tasks().Payloads())-existingTaskCount)
+				for _, pl := range skdr.Tasks().Payloads()[existingTaskCount:] {
+					switch pl.(type) {
+					case *taskspb.IngestTestResults:
+						tasks = append(tasks, pl.(*taskspb.IngestTestResults))
+					default:
+						panic("unexpected task type")
+					}
+				}
+				return processed, tasks
+			}
+
+			run := &cvv0.Run{
+				Id:         fullRunID,
+				Mode:       "FULL_RUN",
+				CreateTime: timestamppb.New(clock.Now(ctx)),
+				Owner:      "cl-owner@google.com",
+				Tryjobs: []*cvv0.Tryjob{
+					tryjob(buildIDs[0]),
+					tryjob(2), // This build has not been ingested yet.
+					tryjob(buildIDs[1]),
+				},
+				Status: cvv0.Run_SUCCEEDED,
+			}
+			expectedTaskTemplate := &taskspb.IngestTestResults{
+				PartitionTime: run.CreateTime,
+				PresubmitRun: &controlpb.PresubmitResult{
+					PresubmitRunId: &pb.PresubmitRunId{
+						System: "luci-cv",
+						Id:     "cvproject/" + strings.Split(run.Id, "/")[3],
+					},
+					Status:       pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED,
+					Mode:         pb.PresubmitRunMode_FULL_RUN,
+					Owner:        "user",
+					CreationTime: run.CreateTime,
+				},
+			}
+			Convey(`Baseline`, func() {
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+
+				Convey(`Re-processing CV run should not result in further ingestion tasks`, func() {
+					processed, tasks = processCVRun(run)
+					So(processed, ShouldBeTrue)
+					So(tasks, ShouldBeEmpty)
+				})
+			})
+			Convey(`Dry run`, func() {
+				run.Mode = "DRY_RUN"
+				expectedTaskTemplate.PresubmitRun.Mode = pb.PresubmitRunMode_DRY_RUN
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+			})
+			Convey(`CV Run owned by Automation`, func() {
+				run.Owner = "chromium-autoroll@skia-public.iam.gserviceaccount.com"
+				expectedTaskTemplate.PresubmitRun.Owner = "automation"
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+			})
+			Convey(`CV Run owned by Automation 2`, func() {
+				run.Owner = "3su6n15k.default@developer.gserviceaccount.com"
+				expectedTaskTemplate.PresubmitRun.Owner = "automation"
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+			})
+			Convey(`With non-buildbucket tryjob`, func() {
+				// Should be ignored.
+				run.Tryjobs = append(run.Tryjobs, &cvv0.Tryjob{
+					Result: &cvv0.Tryjob_Result{},
+				})
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+			})
+			Convey(`With re-used tryjob`, func() {
+				// Assume that this tryjob was created by another CV run,
+				// so should not be ingested with this CV run.
+				run.Tryjobs[0].Reuse = true
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs[1:])))
+			})
+			Convey(`Failing Run`, func() {
+				run.Status = cvv0.Run_FAILED
+				expectedTaskTemplate.PresubmitRun.Status = pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+			})
+			Convey(`Cancelled Run`, func() {
+				run.Status = cvv0.Run_CANCELLED
+				expectedTaskTemplate.PresubmitRun.Status = pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_CANCELED
+
+				processed, tasks := processCVRun(run)
+				So(processed, ShouldBeTrue)
+				So(sortTasks(tasks), ShouldResembleProto,
+					sortTasks(expectedTasks(expectedTaskTemplate, buildIDs)))
+			})
+		})
+	})
+}
+
+func makeCVRunReq(psRun *cvv1.PubSubRun) io.ReadCloser {
+	blob, _ := protojson.Marshal(psRun)
+	return makeReq(blob)
+}
+
+func makeCVChromiumRunReq(runID string) io.ReadCloser {
+	return makeCVRunReq(&cvv1.PubSubRun{
+		Id:       runID,
+		Status:   cvv1.Run_SUCCEEDED,
+		Hostname: "cvhost",
+	})
+}
+
+func tryjob(bID int64) *cvv0.Tryjob {
+	return &cvv0.Tryjob{
+		Result: &cvv0.Tryjob_Result{
+			Backend: &cvv0.Tryjob_Result_Buildbucket_{
+				Buildbucket: &cvv0.Tryjob_Result_Buildbucket{
+					Id: int64(bID),
+				},
+			},
+		},
+		Critical: (bID % 2) == 0,
+	}
+}
+
+func fullRunID(project, runID string) string {
+	return fmt.Sprintf("projects/%s/runs/%s", project, runID)
+}
+
+func expectedTasks(taskTemplate *taskspb.IngestTestResults, buildIDs []int64) []*taskspb.IngestTestResults {
+	res := make([]*taskspb.IngestTestResults, 0, len(buildIDs))
+	for _, buildID := range buildIDs {
+		t := proto.Clone(taskTemplate).(*taskspb.IngestTestResults)
+		t.PresubmitRun.Critical = ((buildID % 2) == 0)
+		t.Build = &controlpb.BuildResult{
+			Host:         bbHost,
+			Id:           buildID,
+			CreationTime: timestamppb.New(bbCreateTime),
+			Project:      "buildproject",
+		}
+		res = append(res, t)
+	}
+	return res
+}
+
+func sortTasks(tasks []*taskspb.IngestTestResults) []*taskspb.IngestTestResults {
+	sort.Slice(tasks, func(i, j int) bool { return tasks[i].Build.Id < tasks[j].Build.Id })
+	return tasks
+}
diff --git a/analysis/app/join.go b/analysis/app/join.go
new file mode 100644
index 0000000..3b56177
--- /dev/null
+++ b/analysis/app/join.go
@@ -0,0 +1,274 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"context"
+	"fmt"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+	ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	"go.chromium.org/luci/analysis/internal/services/resultingester"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+)
+
+// For presubmit builds, proceeding to ingestion is conditional:
+// we must wait for the both the CV run and Buildbucket build to complete.
+// We define the following metrics to monitor the performance of that join.
+var (
+	cvPresubmitBuildInputCounter = metric.NewCounter(
+		"weetbix/ingestion/join/cv_presubmit_builds_input",
+		"The number of unique presubmit builds for which CV Run Completion was received."+
+			" Broken down by project of the CV run.",
+		nil,
+		// The LUCI Project of the CV run.
+		field.String("project"))
+
+	cvPresubmitBuildOutputCounter = metric.NewCounter(
+		"weetbix/ingestion/join/cv_presubmit_builds_output",
+		"The number of presubmit builds which were successfully joined and for which ingestion was queued."+
+			" Broken down by project of the CV run.",
+		nil,
+		// The LUCI Project of the CV run.
+		field.String("project"))
+
+	bbPresubmitBuildInputCounter = metric.NewCounter(
+		"weetbix/ingestion/join/bb_presubmit_builds_input",
+		"The number of unique presubmit build for which buildbucket build completion was received."+
+			" Broken down by project of the buildbucket build.",
+		nil,
+		// The LUCI Project of the buildbucket run.
+		field.String("project"))
+
+	bbPresubmitBuildOutputCounter = metric.NewCounter(
+		"weetbix/ingestion/join/bb_presubmit_builds_output",
+		"The number of presubmit builds which were successfully joined and for which ingestion was queued."+
+			" Broken down by project of the buildbucket build.",
+		nil,
+		// The LUCI Project of the buildbucket run.
+		field.String("project"))
+)
+
+// For CI builds, no actual join needs to occur. So it is sufficient to
+// monitor only the output flow (same as input flow).
+var (
+	outputCIBuildCounter = metric.NewCounter(
+		"weetbix/ingestion/join/ci_builds_output",
+		"The number of CI builds for which ingestion was queued.",
+		nil,
+		// The LUCI Project.
+		field.String("project"))
+)
+
+// JoinBuildResult sets the build result for the given build.
+//
+// An ingestion task is created if all required data for the
+// ingestion is available (for builds part of a presubmit run,
+// this is only after the presubmit result has joined, for
+// all other builds, this is straight away).
+//
+// If the build result has already been provided for a build,
+// this method has no effect.
+func JoinBuildResult(ctx context.Context, buildID, buildProject string, isPresubmit bool, br *ctlpb.BuildResult) error {
+	if br == nil {
+		return errors.New("build result must be specified")
+	}
+	var saved bool
+	var taskCreated bool
+	var cvProject string
+	f := func(ctx context.Context) error {
+		// Clear variables to ensure nothing from a previous (failed)
+		// try of this transaction leaks out to the outer context.
+		saved = false
+		taskCreated = false
+		cvProject = ""
+
+		entries, err := control.Read(ctx, []string{buildID})
+		if err != nil {
+			return err
+		}
+		entry := entries[0]
+		if entry == nil {
+			// Record does not exist. Create it.
+			entry = &control.Entry{
+				BuildID:     buildID,
+				IsPresubmit: isPresubmit,
+			}
+		}
+		if entry.IsPresubmit != isPresubmit {
+			return fmt.Errorf("disagreement about whether ingestion is presubmit run (got %v, want %v)", isPresubmit, entry.IsPresubmit)
+		}
+		if entry.BuildResult != nil {
+			// Build result already recorded. Do not modify and do not
+			// create a duplicate ingestion.
+			return nil
+		}
+		entry.BuildProject = buildProject
+		entry.BuildResult = br
+		entry.BuildJoinedTime = spanner.CommitTimestamp
+
+		saved = true
+		taskCreated = createTasksIfNeeded(ctx, entry)
+		if taskCreated {
+			entry.TaskCount = 1
+		}
+
+		if err := control.InsertOrUpdate(ctx, entry); err != nil {
+			return err
+		}
+		// Will only populated if IsPresubmit is not empty.
+		cvProject = entry.PresubmitProject
+		return nil
+	}
+	if _, err := span.ReadWriteTransaction(ctx, f); err != nil {
+		return err
+	}
+	if !saved {
+		logging.Warningf(ctx, "build result for ingestion %q was dropped as one was already recorded", buildID)
+	}
+
+	// Export metrics.
+	if saved && isPresubmit {
+		bbPresubmitBuildInputCounter.Add(ctx, 1, buildProject)
+	}
+	if taskCreated {
+		if isPresubmit {
+			bbPresubmitBuildOutputCounter.Add(ctx, 1, buildProject)
+			cvPresubmitBuildOutputCounter.Add(ctx, 1, cvProject)
+		} else {
+			outputCIBuildCounter.Add(ctx, 1, buildProject)
+		}
+	}
+	return nil
+}
+
+// JoinPresubmitResult sets the presubmit result for the given builds.
+//
+// Ingestion task(s) are created for builds where all required data
+// is available (i.e. after the build result has also joined).
+//
+// If the presubmit result has already been provided for a build,
+// this method has no effect.
+func JoinPresubmitResult(ctx context.Context, presubmitResultByBuildID map[string]*ctlpb.PresubmitResult, presubmitProject string) error {
+	for id, result := range presubmitResultByBuildID {
+		if result == nil {
+			return fmt.Errorf("presubmit result for build %v must be specified", id)
+		}
+	}
+
+	var buildIDsSkipped []string
+	var buildsOutputByBuildProject map[string]int64
+	f := func(ctx context.Context) error {
+		// Clear variables to ensure nothing from a previous (failed)
+		// try of this transaction leaks out to the outer context.
+		buildIDsSkipped = nil
+		buildsOutputByBuildProject = make(map[string]int64)
+
+		var buildIDs []string
+		for id := range presubmitResultByBuildID {
+			buildIDs = append(buildIDs, id)
+		}
+
+		entries, err := control.Read(ctx, buildIDs)
+		if err != nil {
+			return err
+		}
+		for i, entry := range entries {
+			buildID := buildIDs[i]
+			if entry == nil {
+				// Record does not exist. Create it.
+				entry = &control.Entry{
+					BuildID:     buildID,
+					IsPresubmit: true,
+				}
+			}
+			if !entry.IsPresubmit {
+				return fmt.Errorf("attempt to save presubmit result on build (%q) not marked as presubmit", buildID)
+			}
+			if entry.PresubmitResult != nil {
+				// Presubmit result already recorded. Do not modify and do not
+				// create a duplicate ingestion.
+				buildIDsSkipped = append(buildIDsSkipped, buildID)
+				continue
+			}
+			entry.PresubmitProject = presubmitProject
+			entry.PresubmitResult = presubmitResultByBuildID[buildID]
+			entry.PresubmitJoinedTime = spanner.CommitTimestamp
+
+			taskCreated := createTasksIfNeeded(ctx, entry)
+			if taskCreated {
+				buildsOutputByBuildProject[entry.BuildProject]++
+				entry.TaskCount = 1
+			}
+			if err := control.InsertOrUpdate(ctx, entry); err != nil {
+				return err
+			}
+		}
+		return nil
+	}
+	if _, err := span.ReadWriteTransaction(ctx, f); err != nil {
+		return err
+	}
+	if len(buildIDsSkipped) > 0 {
+		logging.Warningf(ctx, "presubmit result for builds %v were dropped as one was already recorded", buildIDsSkipped)
+	}
+
+	// Export metrics.
+	cvPresubmitBuildInputCounter.Add(ctx, int64(len(presubmitResultByBuildID)-len(buildIDsSkipped)), presubmitProject)
+	for buildProject, count := range buildsOutputByBuildProject {
+		bbPresubmitBuildOutputCounter.Add(ctx, count, buildProject)
+		cvPresubmitBuildOutputCounter.Add(ctx, count, presubmitProject)
+	}
+	return nil
+}
+
+// createTaskIfNeeded transactionally creates a test-result-ingestion task
+// if all necessary data for the ingestion is available. Returns true if the
+// task was created.
+func createTasksIfNeeded(ctx context.Context, e *control.Entry) bool {
+	if e.BuildResult == nil || (e.IsPresubmit && e.PresubmitResult == nil) {
+		return false
+	}
+
+	var itrTask *taskspb.IngestTestResults
+	if e.IsPresubmit {
+		itrTask = &taskspb.IngestTestResults{
+			PartitionTime: e.PresubmitResult.CreationTime,
+			Build:         e.BuildResult,
+			PresubmitRun:  e.PresubmitResult,
+		}
+	} else {
+		itrTask = &taskspb.IngestTestResults{
+			PartitionTime: e.BuildResult.CreationTime,
+			Build:         e.BuildResult,
+		}
+	}
+
+	// Copy the task to avoid aliasing issues if the caller ever
+	// decides the modify e.PresubmitResult or e.BuildResult
+	// after we return.
+	itrTask = proto.Clone(itrTask).(*taskspb.IngestTestResults)
+	resultingester.Schedule(ctx, itrTask)
+
+	return true
+}
diff --git a/analysis/app/main_test.go b/analysis/app/main_test.go
new file mode 100644
index 0000000..86fd333
--- /dev/null
+++ b/analysis/app/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/app/pubsub.go b/analysis/app/pubsub.go
new file mode 100644
index 0000000..bb79bf3
--- /dev/null
+++ b/analysis/app/pubsub.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"net/http"
+
+	"go.chromium.org/luci/common/retry/transient"
+	"go.chromium.org/luci/server/router"
+)
+
+// Sent by pubsub.
+// This struct is just convenient for unwrapping the json message.
+// See https://source.chromium.org/chromium/infra/infra/+/main:luci/appengine/components/components/pubsub.py;l=178;drc=78ce3aa55a2e5f77dc05517ef3ec377b3f36dc6e.
+type pubsubMessage struct {
+	Message struct {
+		Data []byte
+	}
+	Attributes map[string]interface{}
+}
+
+func processErr(ctx *router.Context, err error) string {
+	if transient.Tag.In(err) {
+		// Transient errors are 500 so that PubSub retries them.
+		ctx.Writer.WriteHeader(http.StatusInternalServerError)
+		return "transient-failure"
+	} else {
+		// Permanent failures are 202s so that:
+		// - PubSub does not retry them, and
+		// - the results can be distinguished from success / ignored results
+		//   (which are reported as 200 OK / 204 No Content) in logs.
+		// See https://cloud.google.com/pubsub/docs/push#receiving_messages.
+		ctx.Writer.WriteHeader(http.StatusAccepted)
+		return "permanent-failure"
+	}
+}
diff --git a/analysis/app/pubsub_test.go b/analysis/app/pubsub_test.go
new file mode 100644
index 0000000..10aa7f4
--- /dev/null
+++ b/analysis/app/pubsub_test.go
@@ -0,0 +1,36 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package app
+
+import (
+	"bytes"
+	"encoding/json"
+	"io"
+	"io/ioutil"
+
+	// Needed to ensure task class is registered.
+	_ "go.chromium.org/luci/analysis/internal/services/resultingester"
+)
+
+func makeReq(blob []byte) io.ReadCloser {
+	msg := struct {
+		Message struct {
+			Data []byte
+		}
+		Attributes map[string]interface{}
+	}{struct{ Data []byte }{Data: blob}, nil}
+	jmsg, _ := json.Marshal(msg)
+	return ioutil.NopCloser(bytes.NewReader(jmsg))
+}
diff --git a/analysis/configs/projects/chromium/.gitignore b/analysis/configs/projects/chromium/.gitignore
new file mode 100644
index 0000000..9e35ecd
--- /dev/null
+++ b/analysis/configs/projects/chromium/.gitignore
@@ -0,0 +1 @@
+/chops-weetbix-dev.cfg
diff --git a/analysis/configs/projects/chromium/chops-weetbix-dev-template.cfg b/analysis/configs/projects/chromium/chops-weetbix-dev-template.cfg
new file mode 100644
index 0000000..db55ba5
--- /dev/null
+++ b/analysis/configs/projects/chromium/chops-weetbix-dev-template.cfg
@@ -0,0 +1,139 @@
+# Copyright (c) 2021 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# Copy this file to chops-weetbix-dev.cfg and edit it as needed for running
+# the local development instance.  See the README in the parent directory
+# for more details.
+
+# For the schema of this file, see ProjectConfig message:
+# https://luci-config.appspot.com/schemas/projects:chops-weetbix.cfg
+
+project_metadata {
+  display_name: "Chromium"
+}
+
+
+bug_filing_threshold {
+  presubmit_runs_failed {
+    one_day: 3
+    seven_day: 3
+  }
+}
+
+monorail {
+  project: "chromium"
+  default_field_values {
+    field_id: 10
+    value: "Bug"
+  }
+  priority_field_id: 11
+  priorities {
+    priority: "0"
+    threshold {
+      presubmit_runs_failed {
+        one_day: 20
+      }
+    }
+  }
+  priorities {
+    priority: "1"
+    threshold {
+      presubmit_runs_failed {
+        one_day: 10
+      }
+    }
+  }
+  priorities {
+    priority: "2"
+    threshold {
+      presubmit_runs_failed {
+        one_day: 2
+      }
+    }
+  }
+  priorities {
+    priority: "3"
+    threshold {
+      # Clusters which fail to meet this threshold will be closed.
+      test_results_failed {
+        one_day: 2
+      }
+      presubmit_runs_failed {
+        one_day: 1
+        seven_day: 1
+      }
+    }
+  }
+  priority_hysteresis_percent: 30
+  monorail_hostname: "bugs.chromium.org"
+  display_prefix: "crbug.com"
+}
+
+realms {
+  name: "ci"
+  test_variant_analysis {
+    update_test_variant_task {
+      update_test_variant_task_interval {
+        seconds: 3600
+      }
+      test_variant_status_update_duration {
+        seconds: 86400
+      }
+    }
+    bq_exports {
+      table {
+        cloud_project: "chrome-flakiness"
+        dataset: "chromium_dev"
+        table: "ci_flaky_test_variants"
+      }
+      predicate {
+        # Flaky test variant only.
+        status: FLAKY
+      }
+    }
+  }
+}
+
+realms {
+  name: "try"
+  test_variant_analysis {
+    update_test_variant_task {
+      update_test_variant_task_interval {
+        seconds: 3600
+      }
+      test_variant_status_update_duration {
+        seconds: 86400
+      }
+    }
+    bq_exports {
+      table {
+        cloud_project: "chrome-flakiness"
+        dataset: "chromium_dev"
+        table: "try_flaky_test_variants"
+      }
+      predicate {
+        # Flaky test variant only.
+        status: FLAKY
+      }
+    }
+  }
+}
+
+clustering {
+  test_name_rules {
+		name: "Blink Web Tests"
+		pattern: "^ninja://:blink_web_tests/(virtual/[^/]+/)?(?P<testname>([^/]+/)+[^/]+\\.[a-zA-Z]+).*$"
+		like_template: "ninja://:blink\\_web\\_tests/%${testname}%"
+	}
+  test_name_rules {
+		name: "Google Test (Value-parameterized)"
+		pattern: "^ninja:(?P<target>[\\w/]+:\\w+)/(\\w+/)?(?P<suite>\\w+)\\.(?P<case>\\w+)/\\w+$"
+		like_template: "ninja:${target}/%${suite}.${case}%"
+  }
+  test_name_rules {
+		name: "Google Test (Type-parameterized)"
+		pattern: "^ninja:(?P<target>[\\w/]+:\\w+)/(\\w+/)?(?P<suite>\\w+)/\\w+\\.(?P<case>\\w+)$"
+		like_template: "ninja:${target}/%${suite}/%.${case}"
+  }
+}
diff --git a/analysis/configs/services/chops-weetbix-dev/.gitignore b/analysis/configs/services/chops-weetbix-dev/.gitignore
new file mode 100644
index 0000000..67c7e45
--- /dev/null
+++ b/analysis/configs/services/chops-weetbix-dev/.gitignore
@@ -0,0 +1 @@
+/config.cfg
diff --git a/analysis/configs/services/chops-weetbix-dev/config-template.cfg b/analysis/configs/services/chops-weetbix-dev/config-template.cfg
new file mode 100644
index 0000000..1934cc1
--- /dev/null
+++ b/analysis/configs/services/chops-weetbix-dev/config-template.cfg
@@ -0,0 +1,15 @@
+# Copyright (c) 2021 The Chromium Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+# Copy this file to config.cfg and edit it as needed for running the
+# local development instance.  See the README in the parent directory
+# for more details.
+
+# For the schema of this file, see Config message:
+# https://luci-config.appspot.com/schemas/services/chops-weetbix:config.cfg
+
+monorail_hostname: "monorail-staging.appspot.com"
+chunk_gcs_bucket: "chops-weetbix-dev-chunks"
+reclustering_workers: 50
+reclustering_interval_minutes: 5
diff --git a/analysis/frontend/app.yaml b/analysis/frontend/app.yaml
new file mode 100644
index 0000000..2035473
--- /dev/null
+++ b/analysis/frontend/app.yaml
@@ -0,0 +1,57 @@
+runtime: go116
+instance_class: F4
+service: default
+
+# Currently optimised for the backend. If frontend latency becomes
+# an issue, we can split backend into a separate service.
+automatic_scaling:
+  target_throughput_utilization: 0.90
+  target_cpu_utilization: 0.90
+  max_concurrent_requests: 20
+
+# Note: this is interpreted by gae.py, it maps the app ID being deployed to
+# values of ${...} vars.
+# Reference: https://chromium.googlesource.com/infra/luci/luci-go/+/HEAD/examples/appengine/helloworld_v2/app.yaml
+#
+# This configuration is only used for developer testing. The
+# configuration used for development and production instances is
+# contained in the infradata/gae repo. Refer to LUCI GAE Automatic
+# Deployment for more (go/luci/how_to_deploy.md) (Googlers only).
+luci_gae_vars:
+  chops-weetbix-dev:
+    AUTH_SERVICE_HOST: chrome-infra-auth-dev.appspot.com
+    CONFIG_SERVICE_HOST: luci-config.appspot.com
+    TS_MON_ACCOUNT: app-engine-metric-publishers@prodx-mon-chrome-infra.google.com.iam.gserviceaccount.com
+    OAUTH_CLIENT_ID: 736503773201-aq5uttdlcibgrs26jrtd3r40ft2moc9i.apps.googleusercontent.com
+    OAUTH_CLIENT_SECRET: sm://oauth-client-secret
+    OAUTH_REDIRECT_URL: https://chops-weetbix-dev.appspot.com/auth/openid/callback
+    ROOT_SECRET: sm://root-secret
+    TINK_AEAD_KEY: sm://tink-aead-primary
+    SPANNER_DB: projects/chops-weetbix-dev/instances/dev/databases/chops-weetbix-dev
+
+handlers:
+- url: /static
+  static_dir: ui/dist
+  secure: always
+
+- url: /_ah/push-handlers/.*
+  script: auto
+  login: admin
+  secure: always
+
+- url: /.*
+  script: auto
+  secure: always
+
+entrypoint: >
+  main
+  -auth-service-host ${AUTH_SERVICE_HOST}
+  -config-service-host ${CONFIG_SERVICE_HOST}
+  -ts-mon-account ${TS_MON_ACCOUNT}
+  -encrypted-cookies-client-id ${OAUTH_CLIENT_ID}
+  -frontend-client-id ${OAUTH_CLIENT_ID}
+  -encrypted-cookies-client-secret ${OAUTH_CLIENT_SECRET}
+  -encrypted-cookies-redirect-url ${OAUTH_REDIRECT_URL}
+  -encrypted-cookies-tink-aead-key ${TINK_AEAD_KEY}
+  -root-secret ${ROOT_SECRET}
+  -spanner-database ${SPANNER_DB}
diff --git a/analysis/frontend/cron.yaml b/analysis/frontend/cron.yaml
new file mode 100644
index 0000000..8de8d19
--- /dev/null
+++ b/analysis/frontend/cron.yaml
@@ -0,0 +1,30 @@
+cron:
+- description: "Read configs from LUCI-config"
+  url: /internal/cron/read-config
+  schedule: every 10 minutes
+- description: "Update analysis and create/update bugs for high-impact clusters"
+  url: /internal/cron/update-analysis-and-bugs
+  schedule: every 15 minutes synchronized
+- description: "Sweeper job for transactional tasks."
+  url: /internal/tasks/c/sweep
+  schedule: every 1 minutes
+- description: "Trigger ExportTestVariant jobs on a schedule."
+  url: /internal/cron/export-test-variants
+  # Note: to update the schedule, you also need to update
+  # ScheduleTasks at weetbix/internal/services/testvariantbqexporter/task.go
+  # to make sure the new schedule and time range of each row matches.
+  schedule: every 1 hours from 00:00 to 23:00
+  retry_parameters:
+    # Do not retry after 55 minutes.
+    job_age_limit: 55m
+- description: "Purge test variants that have been consistently expected or no new results for over a month."
+  url: /internal/cron/purge-test-variants
+  schedule: every 60 minutes
+- description: "Orchestrate re-clustering of test results."
+  url: /internal/cron/reclustering
+  # The actual reclustering interval is specified in the system config
+  # as reclustering_interval_minutes. This just triggers the orchestrator.
+  schedule: every 1 minutes synchronized
+- description: "Global metrics reporting."
+  url: /internal/cron/global-metrics
+  schedule: every 10 minutes synchronized
\ No newline at end of file
diff --git a/analysis/frontend/handlers/auth_state.go b/analysis/frontend/handlers/auth_state.go
new file mode 100644
index 0000000..f8d5485
--- /dev/null
+++ b/analysis/frontend/handlers/auth_state.go
@@ -0,0 +1,99 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+	"net/http"
+
+	"go.chromium.org/luci/auth/identity"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/router"
+)
+
+type authState struct {
+	// Identity string of the user (anonymous:anonymous if the user is not
+	// logged in).
+	Identity string `json:"identity"`
+	// The email of the user. Optional, default "".
+	Email string `json:"email"`
+	// The URL of the user avatar. Optional, default "".
+	Picture string `json:"picture"`
+	// The token that authorizes and authenticates the requests.
+	// Used for authenticating to many LUCI services, including Weetbix.
+	AccessToken string `json:"accessToken"`
+	// Expiration time (unix timestamp) of the access token.
+	// If zero, the access token does not expire.
+	AccessTokenExpiry int64 `json:"accessTokenExpiry"`
+	// The OAuth ID token. Used for authenticating to Monorail.
+	IDToken string `json:"idToken"`
+	// Expiration time (unix timestamp) of the ID token.
+	// If zero, the access token does not expire.
+	IDTokenExpiry int64 `json:"idTokenExpiry"`
+}
+
+// GetAuthState returns data about the current user and the access token
+// associated with the current session.
+func (*Handlers) GetAuthState(ctx *router.Context) {
+	fetchSite := ctx.Request.Header.Get("Sec-Fetch-Site")
+	// Only allow the user to directly navigate to this page (for testing),
+	// i.e. "none", or a same-origin request, i.e. "same-origin".
+	// The user's OAuth token should never be returned in a cross-origin
+	// request.
+	if fetchSite != "none" && fetchSite != "same-origin" {
+		http.Error(ctx.Writer, "Request must be a same-origin request.", http.StatusForbidden)
+		return
+	}
+
+	user := auth.CurrentUser(ctx.Context)
+	var state *authState
+	if user.Identity == identity.AnonymousIdentity {
+		state = &authState{
+			Identity: string(user.Identity),
+		}
+	} else {
+		session := auth.GetState(ctx.Context).Session()
+		if session == nil {
+			http.Error(ctx.Writer, "Request not authenticated via secure cookies.", http.StatusUnauthorized)
+			return
+		}
+
+		accessToken, err := session.AccessToken(ctx.Context)
+		if err != nil {
+			logging.Errorf(ctx.Context, "Obtain access token: %s", err)
+			http.Error(ctx.Writer, "Internal server error.", http.StatusInternalServerError)
+			return
+		}
+		idToken, err := session.IDToken(ctx.Context)
+		if err != nil {
+			// If get here when running the server locally, it may mean you need to run
+			// "luci-auth".
+			logging.Errorf(ctx.Context, "Obtain ID token: %s", err)
+			http.Error(ctx.Writer, "Internal server error.", http.StatusInternalServerError)
+			return
+		}
+		state = &authState{
+			Identity:          string(user.Identity),
+			Email:             user.Email,
+			Picture:           user.Picture,
+			AccessToken:       accessToken.AccessToken,
+			AccessTokenExpiry: accessToken.Expiry.Unix(),
+			IDToken:           idToken.AccessToken,
+			IDTokenExpiry:     idToken.Expiry.Unix(),
+		}
+	}
+
+	respondWithJSON(ctx, state)
+}
diff --git a/analysis/frontend/handlers/cron.go b/analysis/frontend/handlers/cron.go
new file mode 100644
index 0000000..568f55e
--- /dev/null
+++ b/analysis/frontend/handlers/cron.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+	"context"
+
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/analysis/internal/bugs/updater"
+	"go.chromium.org/luci/analysis/internal/config"
+)
+
+// UpdateAnalysisAndBugs handles the update-analysis-and-bugs cron job.
+func (h *Handlers) UpdateAnalysisAndBugs(ctx context.Context) error {
+	cfg, err := config.Get(ctx)
+	if err != nil {
+		return errors.Annotate(err, "get config").Err()
+	}
+	simulate := !h.prod
+	enabled := cfg.BugUpdatesEnabled
+	err = updater.UpdateAnalysisAndBugs(ctx, cfg.MonorailHostname, h.cloudProject, simulate, enabled)
+	if err != nil {
+		return errors.Annotate(err, "update bugs").Err()
+	}
+	return nil
+}
diff --git a/analysis/frontend/handlers/handlers.go b/analysis/frontend/handlers/handlers.go
new file mode 100644
index 0000000..af7ca5b
--- /dev/null
+++ b/analysis/frontend/handlers/handlers.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+	"encoding/json"
+	"net/http"
+
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/server/router"
+)
+
+// Handlers provides methods servicing Weetbix HTTP routes.
+type Handlers struct {
+	cloudProject string
+	// prod is set when running in production (not a dev workstation).
+	prod bool
+}
+
+// NewHandlers initialises a new Handlers instance.
+func NewHandlers(cloudProject string, prod bool) *Handlers {
+	return &Handlers{cloudProject: cloudProject, prod: prod}
+}
+
+func respondWithJSON(ctx *router.Context, data interface{}) {
+	bytes, err := json.Marshal(data)
+	if err != nil {
+		logging.Errorf(ctx.Context, "Marshalling JSON for response: %s", err)
+		http.Error(ctx.Writer, "Internal server error.", http.StatusInternalServerError)
+		return
+	}
+	ctx.Writer.Header().Add("Content-Type", "application/json")
+	if _, err := ctx.Writer.Write(bytes); err != nil {
+		logging.Errorf(ctx.Context, "Writing JSON response: %s", err)
+	}
+}
diff --git a/analysis/frontend/handlers/index.go b/analysis/frontend/handlers/index.go
new file mode 100644
index 0000000..73552ce
--- /dev/null
+++ b/analysis/frontend/handlers/index.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+	"go.chromium.org/luci/server/router"
+	"go.chromium.org/luci/server/templates"
+)
+
+// IndexPage serves a GET request for the index page.
+func (h *Handlers) IndexPage(ctx *router.Context) {
+	templates.MustRender(ctx.Context, ctx.Writer, "pages/index.html", templates.Args{})
+}
diff --git a/analysis/frontend/handlers/routes.go b/analysis/frontend/handlers/routes.go
new file mode 100644
index 0000000..3d66d9c
--- /dev/null
+++ b/analysis/frontend/handlers/routes.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+	"go.chromium.org/luci/server/router"
+)
+
+// RegisterRoutes registers routes explicitly handled by the handler.
+func (h *Handlers) RegisterRoutes(r *router.Router, mw router.MiddlewareChain) {
+	r.GET("/api/authState", mw, h.GetAuthState)
+	r.GET("/", mw, h.IndexPage)
+}
diff --git a/analysis/frontend/handlers/routes_test.go b/analysis/frontend/handlers/routes_test.go
new file mode 100644
index 0000000..e288afe
--- /dev/null
+++ b/analysis/frontend/handlers/routes_test.go
@@ -0,0 +1,31 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package handlers
+
+import (
+	"go.chromium.org/luci/server/router"
+)
+
+const testProject = "testproject"
+
+// routerForTesting returns a *router.Router to use for testing
+// handlers.
+func routerForTesting() *router.Router {
+	router := router.New()
+	prod := true
+	h := NewHandlers("cloud-project", prod)
+	h.RegisterRoutes(router, nil)
+	return router
+}
diff --git a/analysis/frontend/main.go b/analysis/frontend/main.go
new file mode 100644
index 0000000..a0ac702
--- /dev/null
+++ b/analysis/frontend/main.go
@@ -0,0 +1,201 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+	"context"
+	"net/http"
+
+	"go.chromium.org/luci/auth/identity"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/config/server/cfgmodule"
+	"go.chromium.org/luci/grpc/prpc"
+	"go.chromium.org/luci/server"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/cron"
+	"go.chromium.org/luci/server/encryptedcookies"
+	_ "go.chromium.org/luci/server/encryptedcookies/session/datastore"
+	"go.chromium.org/luci/server/gaeemulation"
+	"go.chromium.org/luci/server/module"
+	"go.chromium.org/luci/server/router"
+	"go.chromium.org/luci/server/secrets"
+	spanmodule "go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/templates"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/app"
+	"go.chromium.org/luci/analysis/frontend/handlers"
+	"go.chromium.org/luci/analysis/internal/admin"
+	adminpb "go.chromium.org/luci/analysis/internal/admin/proto"
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/analyzedtestvariants"
+	"go.chromium.org/luci/analysis/internal/clustering/reclustering/orchestrator"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/metrics"
+	"go.chromium.org/luci/analysis/internal/services/reclustering"
+	"go.chromium.org/luci/analysis/internal/services/resultcollector"
+	"go.chromium.org/luci/analysis/internal/services/resultingester"
+	"go.chromium.org/luci/analysis/internal/services/testvariantbqexporter"
+	"go.chromium.org/luci/analysis/internal/services/testvariantupdator"
+	"go.chromium.org/luci/analysis/internal/span"
+	weetbixpb "go.chromium.org/luci/analysis/proto/v1"
+	"go.chromium.org/luci/analysis/rpc"
+)
+
+// authGroup is the name of the LUCI Auth group that controls whether the user
+// should have access to Weetbix.
+const authGroup = "weetbix-access"
+
+// prepareTemplates configures templates.Bundle used by all UI handlers.
+func prepareTemplates(opts *server.Options) *templates.Bundle {
+	return &templates.Bundle{
+		Loader: templates.FileSystemLoader("templates"),
+		// Controls whether templates are cached.
+		DebugMode: func(context.Context) bool { return !opts.Prod },
+		DefaultArgs: func(ctx context.Context, e *templates.Extra) (templates.Args, error) {
+			logoutURL, err := auth.LogoutURL(ctx, e.Request.URL.RequestURI())
+			if err != nil {
+				return nil, err
+			}
+
+			config, err := config.Get(ctx)
+			if err != nil {
+				return nil, err
+			}
+
+			return templates.Args{
+				"AuthGroup":        authGroup,
+				"AuthServiceHost":  opts.AuthServiceHost,
+				"MonorailHostname": config.MonorailHostname,
+				"UserName":         auth.CurrentUser(ctx).Name,
+				"UserEmail":        auth.CurrentUser(ctx).Email,
+				"UserAvatar":       auth.CurrentUser(ctx).Picture,
+				"LogoutURL":        logoutURL,
+			}, nil
+		},
+	}
+}
+
+// requireAuth is middleware that forces the user to login and checks the
+// user is authorised to use Weetbix before handling any request.
+// If the user is not authorised, a standard "access is denied" page is
+// displayed that allows the user to logout and login again with new
+// credentials.
+func requireAuth(ctx *router.Context, next router.Handler) {
+	user := auth.CurrentIdentity(ctx.Context)
+	if user.Kind() == identity.Anonymous {
+		// User is not logged in.
+		url, err := auth.LoginURL(ctx.Context, ctx.Request.URL.RequestURI())
+		if err != nil {
+			logging.Errorf(ctx.Context, "Fetching LoginURL: %s", err.Error())
+			http.Error(ctx.Writer, "Internal server error while fetching Login URL.", http.StatusInternalServerError)
+		} else {
+			http.Redirect(ctx.Writer, ctx.Request, url, http.StatusFound)
+		}
+		return
+	}
+
+	isAuthorised, err := auth.IsMember(ctx.Context, authGroup)
+	switch {
+	case err != nil:
+		logging.Errorf(ctx.Context, "Checking Auth Membership: %s", err.Error())
+		http.Error(ctx.Writer, "Internal server error while checking authorisation.", http.StatusInternalServerError)
+	case !isAuthorised:
+		ctx.Writer.WriteHeader(http.StatusForbidden)
+		templates.MustRender(ctx.Context, ctx.Writer, "pages/access-denied.html", nil)
+	default:
+		next(ctx)
+	}
+}
+
+func pageBase(srv *server.Server) router.MiddlewareChain {
+	return router.NewMiddlewareChain(
+		auth.Authenticate(srv.CookieAuth),
+		templates.WithTemplates(prepareTemplates(&srv.Options)),
+		requireAuth,
+	)
+}
+
+func main() {
+	modules := []module.Module{
+		cfgmodule.NewModuleFromFlags(),
+		cron.NewModuleFromFlags(),
+		encryptedcookies.NewModuleFromFlags(), // Required for auth sessions.
+		gaeemulation.NewModuleFromFlags(),     // Needed by cfgmodule.
+		secrets.NewModuleFromFlags(),          // Needed by encryptedcookies.
+		spanmodule.NewModuleFromFlags(),
+		tq.NewModuleFromFlags(),
+	}
+	server.Main(nil, modules, func(srv *server.Server) error {
+		mw := pageBase(srv)
+
+		handlers := handlers.NewHandlers(srv.Options.CloudProject, srv.Options.Prod)
+		handlers.RegisterRoutes(srv.Routes, mw)
+		srv.Routes.Static("/static/", mw, http.Dir("./ui/dist"))
+		// Anything that is not found, serve app html and let the client side router handle it.
+		srv.Routes.NotFound(mw, handlers.IndexPage)
+
+		// Register pPRC servers.
+		srv.PRPC.AccessControl = prpc.AllowOriginAll
+		srv.PRPC.Authenticator = &auth.Authenticator{
+			Methods: []auth.Method{
+				&auth.GoogleOAuth2Method{
+					Scopes: []string{"https://www.googleapis.com/auth/userinfo.email"},
+				},
+			},
+		}
+		// TODO(crbug/1082369): Remove this workaround once field masks can be decoded.
+		srv.PRPC.HackFixFieldMasksForJSON = true
+		srv.RegisterUnaryServerInterceptor(span.SpannerDefaultsInterceptor())
+
+		ac, err := analysis.NewClient(srv.Context, srv.Options.CloudProject)
+		if err != nil {
+			return errors.Annotate(err, "creating analysis client").Err()
+		}
+		weetbixpb.RegisterClustersServer(srv.PRPC, rpc.NewClustersServer(ac))
+		weetbixpb.RegisterRulesServer(srv.PRPC, rpc.NewRulesSever())
+		weetbixpb.RegisterProjectsServer(srv.PRPC, rpc.NewProjectsServer())
+		weetbixpb.RegisterInitDataGeneratorServer(srv.PRPC, rpc.NewInitDataGeneratorServer())
+		weetbixpb.RegisterTestVariantsServer(srv.PRPC, rpc.NewTestVariantsServer())
+		weetbixpb.RegisterTestHistoryServer(srv.PRPC, rpc.NewTestHistoryServer())
+		adminpb.RegisterAdminServer(srv.PRPC, admin.CreateServer())
+
+		// GAE crons.
+		cron.RegisterHandler("read-config", config.Update)
+		cron.RegisterHandler("update-analysis-and-bugs", handlers.UpdateAnalysisAndBugs)
+		cron.RegisterHandler("export-test-variants", testvariantbqexporter.ScheduleTasks)
+		cron.RegisterHandler("purge-test-variants", analyzedtestvariants.Purge)
+		cron.RegisterHandler("reclustering", orchestrator.CronHandler)
+		cron.RegisterHandler("global-metrics", metrics.GlobalMetrics)
+
+		// Pub/Sub subscription endpoints.
+		srv.Routes.POST("/_ah/push-handlers/buildbucket", nil, app.BuildbucketPubSubHandler)
+		srv.Routes.POST("/_ah/push-handlers/cvrun", nil, app.CVRunPubSubHandler)
+
+		// Register task queue tasks.
+		if err := reclustering.RegisterTaskHandler(srv); err != nil {
+			return errors.Annotate(err, "register reclustering").Err()
+		}
+		if err := resultingester.RegisterTaskHandler(srv); err != nil {
+			return errors.Annotate(err, "register result ingester").Err()
+		}
+		resultcollector.RegisterTaskClass()
+		testvariantbqexporter.RegisterTaskClass()
+		testvariantupdator.RegisterTaskClass()
+
+		return nil
+	})
+}
diff --git a/analysis/frontend/templates/pages/access-denied.html b/analysis/frontend/templates/pages/access-denied.html
new file mode 100644
index 0000000..0c6bfbd
--- /dev/null
+++ b/analysis/frontend/templates/pages/access-denied.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<title>Access Denied - Weetbix</title>
+<body>
+  <p>Access denied. You must have access to the "{{.AuthGroup}}" group on <a href="https://{{.AuthServiceHost}}">{{.AuthServiceHost}}</a> to access this application.</p>
+  <p>Logged in as {{.User}}. <a href="{{.LogoutURL}}">Log Out</a></p>
+</body>
diff --git a/analysis/frontend/templates/pages/index.html b/analysis/frontend/templates/pages/index.html
new file mode 100644
index 0000000..4599967
--- /dev/null
+++ b/analysis/frontend/templates/pages/index.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+
+<head>
+  <title>Weetbix</title>
+  <link href="/static/main.css" rel="stylesheet">
+  <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
+  <link href="https://fonts.googleapis.com/css?family=Material+Icons&display=block" rel="stylesheet">
+  <script>
+    window.monorailHostname = '{{.MonorailHostname}}';
+    window.fullName = '{{.UserName}}';
+    window.email = '{{.UserEmail}}';
+    window.avatar = '{{.UserAvatar}}';
+    window.logoutUrl = '{{.LogoutURL}}';
+  </script>
+  <!-- Global site tag (gtag.js) - Google Analytics -->
+  <script async src="https://www.googletagmanager.com/gtag/js?id=G-R5K0ZJ8NMC"></script>
+  <script>
+    window.dataLayer = window.dataLayer || [];
+    function gtag(){dataLayer.push(arguments);}
+    gtag('js', new Date());
+
+    gtag('config', 'G-R5K0ZJ8NMC');
+  </script>
+</head>
+
+<body>
+  <div id="app-root"></div>
+  <div id="modal-root"></div>
+  <script src="/static/main.js"></script>
+</body>
\ No newline at end of file
diff --git a/analysis/frontend/ui/.eslintrc.cjs b/analysis/frontend/ui/.eslintrc.cjs
new file mode 100644
index 0000000..8d66094
--- /dev/null
+++ b/analysis/frontend/ui/.eslintrc.cjs
@@ -0,0 +1,66 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// eslint-disable-next-line no-undef
+module.exports = {
+  'env': {
+    'browser': true,
+    'es2021': true,
+  },
+  'extends': [
+    'eslint:recommended',
+    'plugin:react/recommended',
+    'plugin:react-hooks/recommended',
+    'prettier',
+    'google',
+    'plugin:@typescript-eslint/recommended',
+    'plugin:jest/recommended',
+    'plugin:import/recommended',
+    'plugin:import/typescript',
+    'plugin:jsx-a11y/recommended',
+  ],
+  'settings': {
+    'react': {
+      'version': 'detect',
+    },
+  },
+  'parser': '@typescript-eslint/parser',
+  'parserOptions': {
+    'ecmaFeatures': {
+      'jsx': true,
+    },
+    'ecmaVersion': 'latest',
+    'sourceType': 'module',
+  },
+  'plugins': [
+    'react',
+    '@typescript-eslint',
+    'prettier',
+    'jest',
+    'jsx-a11y',
+  ],
+  'rules': {
+    'quotes': ['error', 'single'],
+    'semi': ['error', 'always'],
+    'object-curly-spacing': ['error', 'always', { 'objectsInObjects': true }],
+    'require-jsdoc': 0,
+    'import/order': ['error'],
+    'no-trailing-spaces': 'error',
+    'no-console': ['error', { allow: ['error'] }],
+    'eol-last': ['error', 'always'],
+    'react/jsx-uses-react': 'off',
+    'react/react-in-jsx-scope': 'off',
+    'max-len': 'off',
+  },
+};
diff --git a/analysis/frontend/ui/.gitignore b/analysis/frontend/ui/.gitignore
new file mode 100644
index 0000000..3fd19d7
--- /dev/null
+++ b/analysis/frontend/ui/.gitignore
@@ -0,0 +1,5 @@
+node_modules
+dist
+videos
+cypress/screenshots
+coverage
\ No newline at end of file
diff --git a/analysis/frontend/ui/App.tsx b/analysis/frontend/ui/App.tsx
new file mode 100644
index 0000000..6145a6d
--- /dev/null
+++ b/analysis/frontend/ui/App.tsx
@@ -0,0 +1,82 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './styles/style.css';
+import './src/views/home/home_page';
+import './src/views/bug/bug_page/bug_page.ts';
+import './src/views/clusters/cluster/cluster_page.ts';
+import './src/views/new_rule/new_rule_page.ts';
+import './src/views/clusters/cluster/elements/impact_table';
+
+import React from 'react';
+import {
+  QueryClient,
+  QueryClientProvider,
+} from 'react-query';
+import {
+  Route,
+  Routes,
+} from 'react-router-dom';
+
+import BaseLayout from './src/layouts/base';
+import BugPageWrapper from './src/views/bug/bug_page/bug_page_wrapper';
+import ClusterPageWrapper from './src/views/clusters/cluster/cluster_page_wrapper';
+import ClustersPage from './src/views/clusters/clusters_page';
+import NotFoundPage from './src/views/errors/not_found_page';
+import HomePageWrapper from './src/views/home/home_page_wrapper';
+import NewRulePageWrapper from './src/views/new_rule/new_rule_page_wrapper';
+import Rule from './src/views/rule/rule';
+import RulesPage from './src/views/rules/rules_page';
+import { SnackbarContextWrapper } from './src/context/snackbar_context';
+import FeedbackSnackbar from './src/components/error_snackbar/feedback_snackbar';
+
+const queryClient = new QueryClient(
+    {
+      defaultOptions: {
+        queries: {
+          refetchOnWindowFocus: false,
+        },
+      },
+    },
+);
+
+const App = () => {
+  return (
+    <SnackbarContextWrapper>
+      <QueryClientProvider client={queryClient}>
+        <Routes>
+          <Route path='/' element={<BaseLayout />}>
+            <Route index element={<HomePageWrapper />} />
+            <Route path='b/:bugTracker/:id' element={<BugPageWrapper />} />
+            <Route path='p/:project'>
+              <Route path='rules'>
+                <Route index element={<RulesPage />} />
+                <Route path='new' element={<NewRulePageWrapper />} />
+                <Route path=':id' element={<Rule />} />
+              </Route>
+              <Route path='clusters'>
+                <Route index element={<ClustersPage />} />
+                <Route path=':algorithm/:id' element={<ClusterPageWrapper />} />
+              </Route>
+            </Route>
+            <Route path='*' element={<NotFoundPage />} />
+          </Route>
+        </Routes>
+      </QueryClientProvider>
+      <FeedbackSnackbar />
+    </SnackbarContextWrapper>
+  );
+};
+
+export default App;
diff --git a/analysis/frontend/ui/Makefile b/analysis/frontend/ui/Makefile
new file mode 100644
index 0000000..2fba0ec
--- /dev/null
+++ b/analysis/frontend/ui/Makefile
@@ -0,0 +1,10 @@
+default: help
+
+help:
+	@echo "Available commands:"
+	@sed -n '/^[a-zA-Z0-9_]*:/s/:.*//p' <Makefile
+
+# Called as part of Weetbix build for infra-gae-tarballs-continuous.
+# See infra/build/gae/infra/weetbix.yaml.
+release:
+	npm ci && npm run build
diff --git a/analysis/frontend/ui/cypress.json b/analysis/frontend/ui/cypress.json
new file mode 100644
index 0000000..1875f27
--- /dev/null
+++ b/analysis/frontend/ui/cypress.json
@@ -0,0 +1,5 @@
+{
+    "baseUrl": "http://localhost:8800",
+    "includeShadowDom": true,
+    "defaultCommandTimeout": 7000
+}
\ No newline at end of file
diff --git a/analysis/frontend/ui/cypress/fixtures/place-holder.json b/analysis/frontend/ui/cypress/fixtures/place-holder.json
new file mode 100644
index 0000000..4d51cc6
--- /dev/null
+++ b/analysis/frontend/ui/cypress/fixtures/place-holder.json
@@ -0,0 +1 @@
+"This is a placeholder file to prevent cypress from auto-generating example files"
\ No newline at end of file
diff --git a/analysis/frontend/ui/cypress/integration/bug_page.spec.ts b/analysis/frontend/ui/cypress/integration/bug_page.spec.ts
new file mode 100644
index 0000000..c48b6db
--- /dev/null
+++ b/analysis/frontend/ui/cypress/integration/bug_page.spec.ts
@@ -0,0 +1,62 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import { setupTestRule } from './test_data';
+
+describe('Bug Page', () => {
+  beforeEach(() => {
+    // Login.
+    cy.visit('/').contains('LOGIN').click();
+
+    setupTestRule();
+  });
+
+  it('redirects if single matching rule found', () => {
+    cy.visit('/b/chromium/920867');
+    cy.get('[data-testid=bug]').contains('crbug.com/920867');
+  });
+
+  it('no matching rule exists', () => {
+    cy.visit('/b/chromium/404');
+    cy.get('bug-page').contains('No rule found matching the specified bug (monorail:chromium/404).');
+  });
+
+  it('multiple matching rules found', () => {
+    cy.intercept('POST', '/prpc/weetbix.v1.Rules/LookupBug', (req) => {
+      const requestBody = req.body;
+      assert.deepEqual(requestBody, { system: 'monorail', id: 'chromium/1234' });
+
+      const response = {
+        // This is a real rule that exists in the dev database, the
+        // same used for rule section UI tests.
+        rules: [
+          'projects/chromium/rules/4165d118c919a1016f42e80efe30db59',
+          'projects/chromiumos/rules/1234567890abcedf1234567890abcdef',
+        ],
+      };
+      // Construct pRPC response.
+      const body = ')]}\'' + JSON.stringify(response);
+      req.reply(body, {
+        'X-Prpc-Grpc-Code': '0',
+      });
+    }).as('lookupBug');
+
+    cy.visit('/b/chromium/1234');
+    cy.wait('@lookupBug');
+
+    cy.get('body').contains('chromiumos');
+    cy.get('body').contains('chromium').click();
+
+    cy.get('[data-testid=rule-definition]').contains('test = "cypress test 1"');
+  });
+});
diff --git a/analysis/frontend/ui/cypress/integration/clusters.spec.ts b/analysis/frontend/ui/cypress/integration/clusters.spec.ts
new file mode 100644
index 0000000..cb6d7a6
--- /dev/null
+++ b/analysis/frontend/ui/cypress/integration/clusters.spec.ts
@@ -0,0 +1,37 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+describe('Clusters Page', () => {
+  beforeEach(() => {
+    cy.visit('/').contains('LOGIN').click();
+    cy.get('body').contains('Logout');
+    cy.visit('/p/chromium/clusters');
+  });
+  it('loads rules table', () => {
+    // Navigate to the bug cluster page
+    cy.contains('Rules').click();
+    // check for the header text in the bug cluster table.
+    cy.contains('Rule Definition');
+  });
+  it('loads cluster table', () => {
+    // check for an entry in the cluster table.
+    cy.get('[data-testid=clusters_table_body]').contains('test = ');
+  });
+  it('loads a cluster page', () => {
+    cy.get('[data-testid=clusters_table_title] > a').first().click();
+    cy.get('body').contains('Recent Failures');
+    // Check that the analysis section is showing at least one group.
+    cy.get('[data-testid=failures_table_group_cell]');
+  });
+});
diff --git a/analysis/frontend/ui/cypress/integration/home_page.spec.ts b/analysis/frontend/ui/cypress/integration/home_page.spec.ts
new file mode 100644
index 0000000..39917ad
--- /dev/null
+++ b/analysis/frontend/ui/cypress/integration/home_page.spec.ts
@@ -0,0 +1,29 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+describe('Home page', () => {
+  beforeEach(() => {
+    // Login.
+    cy.visit('/').contains('LOGIN').click();
+  });
+
+  it('Loads the project list', () => {
+    cy.get('h1')
+        .should('contain', 'Projects');
+    cy.get('project-card')
+        .then((cardElement) => {
+            expect(cardElement).to.exist
+        });
+  });
+});
diff --git a/analysis/frontend/ui/cypress/integration/new_rule_page.spec.ts b/analysis/frontend/ui/cypress/integration/new_rule_page.spec.ts
new file mode 100644
index 0000000..942505c
--- /dev/null
+++ b/analysis/frontend/ui/cypress/integration/new_rule_page.spec.ts
@@ -0,0 +1,96 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+describe('New Rule Page', () => {
+  beforeEach(() => {
+    // Login.
+    cy.visit('/').contains('LOGIN').click();
+  });
+  it('create rule from scratch', () => {
+    cy.visit('/p/chromium/rules/new');
+
+    cy.get('new-rule-page').get('[data-cy=bug-system-dropdown]').contains('crbug.com');
+    cy.get('new-rule-page').get('[data-cy=bug-number-textbox]').get('[type=text]').type('{selectall}101');
+    cy.get('new-rule-page').get('[data-cy=rule-definition-textbox]').get('textarea').type('{selectall}test = "create test 1"');
+
+    cy.intercept('POST', '/prpc/weetbix.v1.Rules/Create', (req) => {
+      const requestBody = req.body;
+      assert.strictEqual(requestBody.rule.ruleDefinition, 'test = "create test 1"');
+      assert.deepEqual(requestBody.rule.bug, { system: 'monorail', id: 'chromium/101' });
+      assert.deepEqual(requestBody.rule.sourceCluster, { algorithm: '', id: '' });
+
+      const response = {
+        project: 'chromium',
+        // This is a real rule that exists in the dev database, the
+        // same used for rule section UI tests.
+        ruleId: '4165d118c919a1016f42e80efe30db59',
+      };
+      // Construct pRPC response.
+      const body = ')]}\'' + JSON.stringify(response);
+      req.reply(body, {
+        'X-Prpc-Grpc-Code': '0',
+      });
+    }).as('createRule');
+
+    cy.get('new-rule-page').get('[data-cy=create-button]').click();
+    cy.wait('@createRule');
+
+    // Verify the rule page loaded.
+    cy.get('body').contains('Associated Bug');
+  });
+  it('create rule from cluster', () => {
+    // Use an invalid rule to ensure it does not get created in dev by
+    // accident.
+    const rule = 'test = CREATE_TEST_2';
+    cy.visit(`/p/chromium/rules/new?rule=${encodeURIComponent(rule)}&sourceAlg=reason-v1&sourceId=1234567890abcedf1234567890abcedf`);
+
+    cy.get('new-rule-page').get('[data-cy=bug-system-dropdown]').contains('crbug.com');
+    cy.get('new-rule-page').get('[data-cy=bug-number-textbox]').get('[type=text]').type('{selectall}101');
+
+    cy.intercept('POST', '/prpc/weetbix.v1.Rules/Create', (req) => {
+      const requestBody = req.body;
+      assert.strictEqual(requestBody.rule.ruleDefinition, 'test = CREATE_TEST_2');
+      assert.deepEqual(requestBody.rule.bug, { system: 'monorail', id: 'chromium/101' });
+      assert.deepEqual(requestBody.rule.sourceCluster, { algorithm: 'reason-v1', id: '1234567890abcedf1234567890abcedf' });
+
+      const response = {
+        project: 'chromium',
+        // This is a real rule that exists in the dev database, the
+        // same used for rule section UI tests.
+        ruleId: '4165d118c919a1016f42e80efe30db59',
+      };
+      // Construct pRPC response.
+      const body = ')]}\'' + JSON.stringify(response);
+      req.reply(body, {
+        'X-Prpc-Grpc-Code': '0',
+      });
+    }).as('createRule');
+
+    cy.get('new-rule-page').get('[data-cy=create-button]').click();
+    cy.wait('@createRule');
+
+    // Verify the rule page loaded.
+    cy.get('body').contains('Associated Bug');
+  });
+  it('displays validation errors', () => {
+    cy.visit('/p/chromium/rules/new');
+    cy.get('new-rule-page').get('[data-cy=bug-system-dropdown]').contains('crbug.com');
+    cy.get('new-rule-page').get('[data-cy=bug-number-textbox]').get('[type=text]').type('{selectall}101');
+    cy.get('new-rule-page').get('[data-cy=rule-definition-textbox]').get('textarea').type('{selectall}test = INVALID');
+
+    cy.get('new-rule-page').get('[data-cy=create-button]').click();
+
+    cy.get('body').contains('Validation error: rule definition is not valid: undeclared identifier "invalid".');
+  });
+});
diff --git a/analysis/frontend/ui/cypress/integration/rule_section.spec.ts b/analysis/frontend/ui/cypress/integration/rule_section.spec.ts
new file mode 100644
index 0000000..13f65a9
--- /dev/null
+++ b/analysis/frontend/ui/cypress/integration/rule_section.spec.ts
@@ -0,0 +1,91 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import { setupTestRule } from './test_data';
+
+describe('Rule Section', () => {
+  beforeEach(() => {
+    // Login.
+    cy.visit('/').contains('LOGIN').click();
+
+    setupTestRule();
+
+    cy.visit('/p/chromium/rules/4165d118c919a1016f42e80efe30db59');
+  });
+
+  it('loads rule', () => {
+    cy.get('[data-testid=bug-summary]').contains('Weetbix Cypress Test Bug');
+    cy.get('[data-testid=bug-status]').contains('Verified');
+    cy.get('[data-testid=rule-definition]').contains('test = "cypress test 1"');
+    cy.get('[data-testid=rule-archived]').contains('No');
+    cy.get('[data-testid=update-bug-toggle]').get('[type=checkbox]').should('be.checked');
+  });
+
+  it('edit rule definition', () => {
+    cy.get('[data-testid=rule-definition-edit]').click();
+    cy.get('[data-testid=rule-input]').type('{selectall}test = "cypress test 2"');
+    cy.get('[data-testid=rule-edit-dialog-save]').click();
+    cy.get('[data-testid=rule-definition]').contains('test = "cypress test 2"');
+    cy.get('[data-testid=reclustering-progress-description]').contains('Weetbix is re-clustering test results');
+  });
+
+  it('validation error while editing rule definition', () => {
+    cy.get('[data-testid=rule-definition-edit]').click();
+    cy.get('[data-testid=rule-input]').type('{selectall}test = "cypress test 2"a');
+    cy.get('[data-testid=rule-edit-dialog-save]').click();
+    cy.get('[data-testid=snackbar]').contains('rule definition is not valid: syntax error: 1:24: unexpected token "a"');
+    cy.get('[data-testid=rule-edit-dialog-cancel]').click();
+    cy.get('[data-testid=rule-definition]').contains('test = "cypress test 1"');
+  });
+
+  it('edit bug', () => {
+    cy.get('[data-testid=bug-edit]').click();
+    cy.get('[data-testid=bug-number').type('{selectall}920869');
+    cy.get('[data-testid=bug-edit-dialog-save]').click();
+    cy.get('[data-testid=bug]').contains('crbug.com/920869');
+    cy.get('[data-testid=bug-summary]').contains('Weetbix Cypress Alternate Test Bug');
+    cy.get('[data-testid=bug-status]').contains('Fixed');
+  });
+
+  it('validation error while editing bug', () => {
+    cy.get('[data-testid=bug-edit]').click();
+    cy.get('[data-testid=bug-number').type('{selectall}125a');
+    cy.get('[data-testid=bug-edit-dialog-save]').click();
+    cy.get('[data-testid=snackbar]').contains('not a valid monorail bug ID');
+    cy.get('[data-testid=bug-edit-dialog-cancel]').click();
+    cy.get('[data-testid=bug]').contains('crbug.com/920867');
+  });
+
+  it('archive and restore', () => {
+    cy.get('[data-testid=rule-archived-toggle]').contains('Archive').click();
+    cy.get('[data-testid=confirm-dialog-cancel]').click();
+    cy.get('[data-testid=rule-archived]').contains('No');
+
+    cy.get('[data-testid=rule-archived-toggle]').contains('Archive').click();
+    cy.get('[data-testid=confirm-dialog-confirm]').click();
+    cy.get('[data-testid=rule-archived]').contains('Yes');
+
+    cy.get('[data-testid=rule-archived-toggle]').contains('Restore').click();
+    cy.get('[data-testid=confirm-dialog-confirm]').click();
+    cy.get('[data-testid=rule-archived]').contains('No');
+  });
+
+  it('toggle bug updates', () => {
+    cy.get('[data-testid=update-bug-toggle]').click();
+    // Cypress assertion should('not.be.checked') does not work for MUI Switch.
+    cy.get('[data-testid=update-bug-toggle]').should('not.have.class', 'Mui-checked');
+
+    cy.get('[data-testid=update-bug-toggle]').click();
+    cy.get('[data-testid=update-bug-toggle]').should('have.class', 'Mui-checked');
+  });
+});
diff --git a/analysis/frontend/ui/cypress/integration/test_data.ts b/analysis/frontend/ui/cypress/integration/test_data.ts
new file mode 100644
index 0000000..ef47365
--- /dev/null
+++ b/analysis/frontend/ui/cypress/integration/test_data.ts
@@ -0,0 +1,50 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export function setupTestRule() {
+    cy.request({
+        url: '/api/authState',
+        headers: {
+            'Sec-Fetch-Site': 'same-origin',
+        }
+    }).then((response) => {
+        assert.strictEqual(response.status, 200);
+        const body = response.body;
+        const accessToken = body.accessToken;
+        assert.isString(accessToken);
+        assert.notEqual(accessToken, '');
+
+        // Set initial rule state.
+        cy.request({
+            method: 'POST',
+            url:  '/prpc/weetbix.v1.Rules/Update',
+            body: {
+                rule: {
+                    name: 'projects/chromium/rules/4165d118c919a1016f42e80efe30db59',
+                    ruleDefinition: 'test = "cypress test 1"',
+                    bug: {
+                        system: 'monorail',
+                        id: 'chromium/920867',
+                    },
+                    isActive: true,
+                    isManagingBug: true,
+                },
+                updateMask: 'ruleDefinition,bug,isActive,isManagingBug',
+            },
+            headers: {
+                Authorization: 'Bearer ' + accessToken,
+            },
+        });
+    });
+}
\ No newline at end of file
diff --git a/analysis/frontend/ui/cypress/plugins/index.ts b/analysis/frontend/ui/cypress/plugins/index.ts
new file mode 100644
index 0000000..3020e99
--- /dev/null
+++ b/analysis/frontend/ui/cypress/plugins/index.ts
@@ -0,0 +1,33 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// eslint-disable-next-line spaced-comment
+/// <reference types="cypress" />
+
+/**
+ * @type {Cypress.PluginConfig}
+ */
+
+export default (
+    _: Cypress.PluginEvents,
+    config: Cypress.PluginConfigOptions
+) => {
+    return Object.assign({}, config, {
+        fixturesFolder: 'cypress/fixtures',
+        integrationFolder: 'cypress/integration',
+        screenshotsFolder: 'cypress/screenshots',
+        videosFolder: 'cypress/videos',
+        supportFile: 'cypress/support/index.js',
+    });
+};
diff --git a/analysis/frontend/ui/cypress/support/index.js b/analysis/frontend/ui/cypress/support/index.js
new file mode 100644
index 0000000..ace8a8d
--- /dev/null
+++ b/analysis/frontend/ui/cypress/support/index.js
@@ -0,0 +1,13 @@
+// Copyright 2021 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
diff --git a/analysis/frontend/ui/cypress/tsconfig.json b/analysis/frontend/ui/cypress/tsconfig.json
new file mode 100644
index 0000000..f8e51db
--- /dev/null
+++ b/analysis/frontend/ui/cypress/tsconfig.json
@@ -0,0 +1,21 @@
+{
+    "compilerOptions": {
+        "target": "esnext",
+        "noUnusedLocals": true,
+        "noUnusedParameters": true,
+        "noImplicitReturns": true,
+        "noFallthroughCasesInSwitch": true,
+        "noImplicitAny": true,
+        "noImplicitThis": true,
+        "lib": [
+            "esnext",
+            "dom"
+        ],
+        "types": [
+            "cypress"
+        ]
+    },
+    "include": [
+        "**/*.ts"
+    ]
+}
\ No newline at end of file
diff --git a/analysis/frontend/ui/esbuild.mjs b/analysis/frontend/ui/esbuild.mjs
new file mode 100644
index 0000000..bf8bf13
--- /dev/null
+++ b/analysis/frontend/ui/esbuild.mjs
@@ -0,0 +1,38 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { sassPlugin } from 'esbuild-sass-plugin';
+import esbuild from 'esbuild';
+
+esbuild.build({
+  entryPoints: ['index.tsx'],
+  bundle: true,
+  inject: ['src/tools/react_shim.ts'],
+  outfile: 'dist/main.js',
+  minify: true,
+  sourcemap: true,
+  plugins: [sassPlugin()],
+  loader: {
+    '.png': 'dataurl',
+    '.woff': 'dataurl',
+    '.woff2': 'dataurl',
+    '.eot': 'dataurl',
+    '.ttf': 'dataurl',
+    '.svg': 'dataurl',
+  },
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+}).catch((_) => {
+  // eslint-disable-next-line no-undef
+  process.exit(1);
+});
diff --git a/analysis/frontend/ui/esbuild_watch.mjs b/analysis/frontend/ui/esbuild_watch.mjs
new file mode 100644
index 0000000..c4b7ff3
--- /dev/null
+++ b/analysis/frontend/ui/esbuild_watch.mjs
@@ -0,0 +1,38 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { sassPlugin } from 'esbuild-sass-plugin';
+import esbuild from 'esbuild';
+
+esbuild.build({
+  entryPoints: ['index.tsx'],
+  bundle: true,
+  inject: ['src/tools/react_shim.ts'],
+  outfile: 'dist/main.js',
+  sourcemap: true,
+  logLevel: 'debug',
+  plugins: [sassPlugin()],
+  watch: true,
+  loader: {
+    '.png': 'dataurl',
+    '.woff': 'dataurl',
+    '.woff2': 'dataurl',
+    '.eot': 'dataurl',
+    '.ttf': 'dataurl',
+    '.svg': 'dataurl',
+  },
+}).catch((e) => {
+  // eslint-disable-next-line no-console
+  console.log(e);
+});
diff --git a/analysis/frontend/ui/index.tsx b/analysis/frontend/ui/index.tsx
new file mode 100644
index 0000000..774bde0
--- /dev/null
+++ b/analysis/frontend/ui/index.tsx
@@ -0,0 +1,33 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import localizedFormat from 'dayjs/plugin/localizedFormat';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import UTC from 'dayjs/plugin/utc';
+import React from 'react';
+import ReactDOM from 'react-dom';
+import { BrowserRouter } from 'react-router-dom';
+
+import App from './App';
+
+dayjs.extend(relativeTime);
+dayjs.extend(UTC);
+dayjs.extend(localizedFormat);
+
+ReactDOM.render(
+    <BrowserRouter>
+      <App />
+    </BrowserRouter>
+    , document.getElementById('app-root'));
diff --git a/analysis/frontend/ui/jest.config.js b/analysis/frontend/ui/jest.config.js
new file mode 100644
index 0000000..a61d4f7
--- /dev/null
+++ b/analysis/frontend/ui/jest.config.js
@@ -0,0 +1,34 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+// eslint-disable-next-line no-undef
+module.exports = {
+  preset: 'ts-jest/presets/js-with-ts',
+  testEnvironment: 'jsdom',
+  testMatch: [
+    '**/__tests__/**/*.[jt]s?(x)',
+    '**/?(*.)+(test).[jt]s?(x)',
+  ],
+  /**
+   * The reason we need to set this is because we are importing node_modules which are using
+   * `es6` modules and that is not compatible with jest, so we need to transform them.
+   */
+  transformIgnorePatterns: ['/node_modules/(?!(lit-element|lit-html|@material|lit|@lit|node-fetch|data-uri-to-buffer|fetch-blob|formdata-polyfill)/)'],
+  moduleNameMapper: {
+    '\\.(css|less)$': 'identity-obj-proxy',
+  },
+  setupFiles: [
+    './src/testing_tools/setUpEnv.ts',
+  ],
+};
diff --git a/analysis/frontend/ui/package-lock.json b/analysis/frontend/ui/package-lock.json
new file mode 100644
index 0000000..417bbcc
--- /dev/null
+++ b/analysis/frontend/ui/package-lock.json
@@ -0,0 +1,21362 @@
+{
+  "name": "weetbix",
+  "version": "1.0.0",
+  "lockfileVersion": 2,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "weetbix",
+      "version": "1.0.0",
+      "dependencies": {
+        "@chopsui/prpc-client": "^1.1.0",
+        "@emotion/react": "^11.8.2",
+        "@fontsource/roboto": "^4.5.3",
+        "@material/mwc-button": "^0.25.3",
+        "@material/mwc-checkbox": "^0.25.3",
+        "@material/mwc-circular-progress": "^0.25.3",
+        "@material/mwc-dialog": "^0.25.3",
+        "@material/mwc-formfield": "^0.25.3",
+        "@material/mwc-icon": "^0.25.3",
+        "@material/mwc-list": "^0.25.3",
+        "@material/mwc-select": "^0.25.3",
+        "@material/mwc-snackbar": "^0.25.3",
+        "@material/mwc-switch": "^0.25.3",
+        "@material/mwc-textarea": "^0.25.3",
+        "@material/mwc-textfield": "^0.25.3",
+        "@mui/icons-material": "^5.8.4",
+        "@mui/lab": "^5.0.0-alpha.92",
+        "@mui/material": "^5.9.2",
+        "@vaadin/router": "^1.7.4",
+        "dayjs": "^1.10.8",
+        "esbuild-sass-plugin": "^2.2.3",
+        "lit-element": "^2.5.1",
+        "luxon": "^2.1.1",
+        "nanoid": "^3.3.3",
+        "react": "^17.0.2",
+        "react-chartjs-2": "^4.0.1",
+        "react-dom": "^17.0.2",
+        "react-query": "^3.34.16",
+        "react-router-dom": "^6.2.2",
+        "react-use": "^17.3.2"
+      },
+      "devDependencies": {
+        "@testing-library/jest-dom": "^5.16.2",
+        "@testing-library/react": "^12.1.4",
+        "@testing-library/react-hooks": "^7.0.2",
+        "@testing-library/user-event": "^13.5.0",
+        "@types/jest": "^27.4.1",
+        "@types/luxon": "^2.0.7",
+        "@types/node": "^17.0.2",
+        "@types/react": "^17.0.39",
+        "@types/react-dom": "^17.0.13",
+        "@typescript-eslint/eslint-plugin": "^5.16.0",
+        "@typescript-eslint/parser": "^5.16.0",
+        "css-loader": "^6.2.0",
+        "cypress": "^8.3.1",
+        "esbuild": "^0.14.10",
+        "eslint": "^8.11.0",
+        "eslint-config-google": "^0.14.0",
+        "eslint-config-prettier": "^8.5.0",
+        "eslint-plugin-import": "^2.25.4",
+        "eslint-plugin-jest": "^26.1.3",
+        "eslint-plugin-jsx-a11y": "^6.5.1",
+        "eslint-plugin-prettier": "^4.0.0",
+        "eslint-plugin-react": "^7.29.4",
+        "eslint-plugin-react-hooks": "^4.5.0",
+        "fetch-mock-jest": "^1.5.1",
+        "identity-obj-proxy": "^3.0.0",
+        "jest": "^27.5.1",
+        "node-fetch": "^3.2.3",
+        "react-test-renderer": "^17.0.2",
+        "sass-loader": "^12.6.0",
+        "style-loader": "^3.2.1",
+        "stylelint-config-css-modules": "^4.1.0",
+        "stylelint-config-recess-order": "^3.0.0",
+        "stylelint-config-standard": "^25.0.0",
+        "stylelint-scss": "^4.2.0",
+        "ts-jest": "^27.1.3",
+        "typescript": "^4.5.4"
+      }
+    },
+    "node_modules/@ampproject/remapping": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
+      "integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
+      "dependencies": {
+        "@jridgewell/trace-mapping": "^0.3.0"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/code-frame": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
+      "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
+      "dependencies": {
+        "@babel/highlight": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/compat-data": {
+      "version": "7.17.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.0.tgz",
+      "integrity": "sha512-392byTlpGWXMv4FbyWw3sAZ/FrW/DrwqLGXpy0mbyNe9Taqv1mg9yON5/o0cnr8XYCkFTZbC1eV+c+LAROgrng==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/core": {
+      "version": "7.17.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
+      "integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
+      "dependencies": {
+        "@ampproject/remapping": "^2.1.0",
+        "@babel/code-frame": "^7.16.7",
+        "@babel/generator": "^7.17.3",
+        "@babel/helper-compilation-targets": "^7.16.7",
+        "@babel/helper-module-transforms": "^7.16.7",
+        "@babel/helpers": "^7.17.2",
+        "@babel/parser": "^7.17.3",
+        "@babel/template": "^7.16.7",
+        "@babel/traverse": "^7.17.3",
+        "@babel/types": "^7.17.0",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.1.2",
+        "semver": "^6.3.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/babel"
+      }
+    },
+    "node_modules/@babel/core/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/generator": {
+      "version": "7.17.3",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.3.tgz",
+      "integrity": "sha512-+R6Dctil/MgUsZsZAkYgK+ADNSZzJRRy0TvY65T71z/CR854xHQ1EweBYXdfT+HNeN7w0cSJJEzgxZMv40pxsg==",
+      "dependencies": {
+        "@babel/types": "^7.17.0",
+        "jsesc": "^2.5.1",
+        "source-map": "^0.5.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/generator/node_modules/source-map": {
+      "version": "0.5.7",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+      "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.7.tgz",
+      "integrity": "sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==",
+      "dependencies": {
+        "@babel/compat-data": "^7.16.4",
+        "@babel/helper-validator-option": "^7.16.7",
+        "browserslist": "^4.17.5",
+        "semver": "^6.3.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@babel/helper-compilation-targets/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/@babel/helper-environment-visitor": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz",
+      "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==",
+      "dependencies": {
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-function-name": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz",
+      "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==",
+      "dependencies": {
+        "@babel/helper-get-function-arity": "^7.16.7",
+        "@babel/template": "^7.16.7",
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-get-function-arity": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz",
+      "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==",
+      "dependencies": {
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-hoist-variables": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz",
+      "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==",
+      "dependencies": {
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-imports": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz",
+      "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==",
+      "dependencies": {
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-module-transforms": {
+      "version": "7.17.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz",
+      "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==",
+      "dependencies": {
+        "@babel/helper-environment-visitor": "^7.16.7",
+        "@babel/helper-module-imports": "^7.16.7",
+        "@babel/helper-simple-access": "^7.17.7",
+        "@babel/helper-split-export-declaration": "^7.16.7",
+        "@babel/helper-validator-identifier": "^7.16.7",
+        "@babel/template": "^7.16.7",
+        "@babel/traverse": "^7.17.3",
+        "@babel/types": "^7.17.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-plugin-utils": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz",
+      "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-simple-access": {
+      "version": "7.17.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz",
+      "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==",
+      "dependencies": {
+        "@babel/types": "^7.17.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-split-export-declaration": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz",
+      "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==",
+      "dependencies": {
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
+      "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-option": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz",
+      "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helpers": {
+      "version": "7.17.2",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.2.tgz",
+      "integrity": "sha512-0Qu7RLR1dILozr/6M0xgj+DFPmi6Bnulgm9M8BVa9ZCWxDqlSnqt3cf8IDPB5m45sVXUZ0kuQAgUrdSFFH79fQ==",
+      "dependencies": {
+        "@babel/template": "^7.16.7",
+        "@babel/traverse": "^7.17.0",
+        "@babel/types": "^7.17.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight": {
+      "version": "7.16.10",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz",
+      "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.16.7",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/ansi-styles": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+      "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+      "dependencies": {
+        "color-convert": "^1.9.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/chalk": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+      "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+      "dependencies": {
+        "ansi-styles": "^3.2.1",
+        "escape-string-regexp": "^1.0.5",
+        "supports-color": "^5.3.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-convert": {
+      "version": "1.9.3",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+      "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+      "dependencies": {
+        "color-name": "1.1.3"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/color-name": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+      "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+    },
+    "node_modules/@babel/highlight/node_modules/has-flag": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+      "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/highlight/node_modules/supports-color": {
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+      "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+      "dependencies": {
+        "has-flag": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.17.3",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.3.tgz",
+      "integrity": "sha512-7yJPvPV+ESz2IUTPbOL+YkIGyCqOyNIzdguKQuJGnH7bg1WTIifuM21YqokFt/THWh1AkCRn9IgoykTRCBVpzA==",
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-async-generators": {
+      "version": "7.8.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+      "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-bigint": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
+      "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-class-properties": {
+      "version": "7.12.13",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+      "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.12.13"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-import-meta": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-json-strings": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+      "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-jsx": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz",
+      "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==",
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-logical-assignment-operators": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+      "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+      "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-numeric-separator": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+      "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-object-rest-spread": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+      "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-optional-catch-binding": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+      "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-optional-chaining": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+      "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-top-level-await": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+      "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.14.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/plugin-syntax-typescript": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz",
+      "integrity": "sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0-0"
+      }
+    },
+    "node_modules/@babel/runtime": {
+      "version": "7.17.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
+      "integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
+      "dependencies": {
+        "regenerator-runtime": "^0.13.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/runtime-corejs3": {
+      "version": "7.17.9",
+      "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz",
+      "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==",
+      "dev": true,
+      "dependencies": {
+        "core-js-pure": "^3.20.2",
+        "regenerator-runtime": "^0.13.4"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/template": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz",
+      "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==",
+      "dependencies": {
+        "@babel/code-frame": "^7.16.7",
+        "@babel/parser": "^7.16.7",
+        "@babel/types": "^7.16.7"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/traverse": {
+      "version": "7.17.3",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz",
+      "integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==",
+      "dependencies": {
+        "@babel/code-frame": "^7.16.7",
+        "@babel/generator": "^7.17.3",
+        "@babel/helper-environment-visitor": "^7.16.7",
+        "@babel/helper-function-name": "^7.16.7",
+        "@babel/helper-hoist-variables": "^7.16.7",
+        "@babel/helper-split-export-declaration": "^7.16.7",
+        "@babel/parser": "^7.17.3",
+        "@babel/types": "^7.17.0",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.17.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz",
+      "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==",
+      "dependencies": {
+        "@babel/helper-validator-identifier": "^7.16.7",
+        "to-fast-properties": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@bcoe/v8-coverage": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+      "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+      "dev": true
+    },
+    "node_modules/@chopsui/prpc-client": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@chopsui/prpc-client/-/prpc-client-1.1.0.tgz",
+      "integrity": "sha512-7ej/IHxKMBXqi62HsnVZeR+OmAotmt48kn48x5ZlDlX1/4Jbzbye9fJ7mJ3pNMRnfA1DKsdsk/Dfedx4eoiEpQ=="
+    },
+    "node_modules/@cspotcode/source-map-consumer": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz",
+      "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/@cspotcode/source-map-support": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz",
+      "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "@cspotcode/source-map-consumer": "0.8.0"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@cypress/request": {
+      "version": "2.88.6",
+      "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.6.tgz",
+      "integrity": "sha512-z0UxBE/+qaESAHY9p9sM2h8Y4XqtsbDCt0/DPOrqA/RZgKi4PkxdpXyK4wCCnSk1xHqWHZZAE+gV6aDAR6+caQ==",
+      "dev": true,
+      "dependencies": {
+        "aws-sign2": "~0.7.0",
+        "aws4": "^1.8.0",
+        "caseless": "~0.12.0",
+        "combined-stream": "~1.0.6",
+        "extend": "~3.0.2",
+        "forever-agent": "~0.6.1",
+        "form-data": "~2.3.2",
+        "har-validator": "~5.1.3",
+        "http-signature": "~1.2.0",
+        "is-typedarray": "~1.0.0",
+        "isstream": "~0.1.2",
+        "json-stringify-safe": "~5.0.1",
+        "mime-types": "~2.1.19",
+        "performance-now": "^2.1.0",
+        "qs": "~6.5.2",
+        "safe-buffer": "^5.1.2",
+        "tough-cookie": "~2.5.0",
+        "tunnel-agent": "^0.6.0",
+        "uuid": "^8.3.2"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/@cypress/xvfb": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
+      "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^3.1.0",
+        "lodash.once": "^4.1.1"
+      }
+    },
+    "node_modules/@cypress/xvfb/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/@emotion/babel-plugin": {
+      "version": "11.7.2",
+      "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.7.2.tgz",
+      "integrity": "sha512-6mGSCWi9UzXut/ZAN6lGFu33wGR3SJisNl3c0tvlmb8XChH1b2SUvxvnOh7hvLpqyRdHHU9AiazV3Cwbk5SXKQ==",
+      "dependencies": {
+        "@babel/helper-module-imports": "^7.12.13",
+        "@babel/plugin-syntax-jsx": "^7.12.13",
+        "@babel/runtime": "^7.13.10",
+        "@emotion/hash": "^0.8.0",
+        "@emotion/memoize": "^0.7.5",
+        "@emotion/serialize": "^1.0.2",
+        "babel-plugin-macros": "^2.6.1",
+        "convert-source-map": "^1.5.0",
+        "escape-string-regexp": "^4.0.0",
+        "find-root": "^1.1.0",
+        "source-map": "^0.5.7",
+        "stylis": "4.0.13"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/@emotion/babel-plugin/node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@emotion/babel-plugin/node_modules/source-map": {
+      "version": "0.5.7",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+      "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/@emotion/cache": {
+      "version": "11.9.3",
+      "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.9.3.tgz",
+      "integrity": "sha512-0dgkI/JKlCXa+lEXviaMtGBL0ynpx4osh7rjOXE71q9bIF8G+XhJgvi+wDu0B0IdCVx37BffiwXlN9I3UuzFvg==",
+      "dependencies": {
+        "@emotion/memoize": "^0.7.4",
+        "@emotion/sheet": "^1.1.1",
+        "@emotion/utils": "^1.0.0",
+        "@emotion/weak-memoize": "^0.2.5",
+        "stylis": "4.0.13"
+      }
+    },
+    "node_modules/@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="
+    },
+    "node_modules/@emotion/is-prop-valid": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.1.3.tgz",
+      "integrity": "sha512-RFg04p6C+1uO19uG8N+vqanzKqiM9eeV1LDOG3bmkYmuOj7NbKNlFC/4EZq5gnwAIlcC/jOT24f8Td0iax2SXA==",
+      "dependencies": {
+        "@emotion/memoize": "^0.7.4"
+      }
+    },
+    "node_modules/@emotion/memoize": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz",
+      "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ=="
+    },
+    "node_modules/@emotion/react": {
+      "version": "11.8.2",
+      "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.8.2.tgz",
+      "integrity": "sha512-+1bcHBaNJv5nkIIgnGKVsie3otS0wF9f1T1hteF3WeVvMNQEtfZ4YyFpnphGoot3ilU/wWMgP2SgIDuHLE/wAA==",
+      "dependencies": {
+        "@babel/runtime": "^7.13.10",
+        "@emotion/babel-plugin": "^11.7.1",
+        "@emotion/cache": "^11.7.1",
+        "@emotion/serialize": "^1.0.2",
+        "@emotion/utils": "^1.1.0",
+        "@emotion/weak-memoize": "^0.2.5",
+        "hoist-non-react-statics": "^3.3.1"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0",
+        "react": ">=16.8.0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@emotion/serialize": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.2.tgz",
+      "integrity": "sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A==",
+      "dependencies": {
+        "@emotion/hash": "^0.8.0",
+        "@emotion/memoize": "^0.7.4",
+        "@emotion/unitless": "^0.7.5",
+        "@emotion/utils": "^1.0.0",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@emotion/sheet": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.1.tgz",
+      "integrity": "sha512-J3YPccVRMiTZxYAY0IOq3kd+hUP8idY8Kz6B/Cyo+JuXq52Ek+zbPbSQUrVQp95aJ+lsAW7DPL1P2Z+U1jGkKA=="
+    },
+    "node_modules/@emotion/styled": {
+      "version": "11.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.8.1.tgz",
+      "integrity": "sha512-OghEVAYBZMpEquHZwuelXcRjRJQOVayvbmNR0zr174NHdmMgrNkLC6TljKC5h9lZLkN5WGrdUcrKlOJ4phhoTQ==",
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "@babel/runtime": "^7.13.10",
+        "@emotion/babel-plugin": "^11.7.1",
+        "@emotion/is-prop-valid": "^1.1.2",
+        "@emotion/serialize": "^1.0.2",
+        "@emotion/utils": "^1.1.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0",
+        "@emotion/react": "^11.0.0-rc.0",
+        "react": ">=16.8.0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@emotion/unitless": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+      "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
+    },
+    "node_modules/@emotion/utils": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.1.0.tgz",
+      "integrity": "sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ=="
+    },
+    "node_modules/@emotion/weak-memoize": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz",
+      "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA=="
+    },
+    "node_modules/@eslint/eslintrc": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz",
+      "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.3.1",
+        "globals": "^13.9.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.0.4",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/globals": {
+      "version": "13.13.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz",
+      "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@eslint/eslintrc/node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@fontsource/roboto": {
+      "version": "4.5.3",
+      "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-4.5.3.tgz",
+      "integrity": "sha512-NUvBTj332dFRdiVkLlavXbDGoD2zyyeGYmMyrXOnctg/3e4pq95+rJgNfUP+k4v8UBk2L1aomGw9dDjbRdAmTg=="
+    },
+    "node_modules/@humanwhocodes/config-array": {
+      "version": "0.9.5",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
+      "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
+      "dev": true,
+      "dependencies": {
+        "@humanwhocodes/object-schema": "^1.2.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=10.10.0"
+      }
+    },
+    "node_modules/@humanwhocodes/object-schema": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+      "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+      "dev": true
+    },
+    "node_modules/@istanbuljs/load-nyc-config": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+      "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+      "dev": true,
+      "dependencies": {
+        "camelcase": "^5.3.1",
+        "find-up": "^4.1.0",
+        "get-package-type": "^0.1.0",
+        "js-yaml": "^3.13.1",
+        "resolve-from": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
+      "version": "1.0.10",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+      "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+      "dev": true,
+      "dependencies": {
+        "sprintf-js": "~1.0.2"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+      "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^1.0.7",
+        "esprima": "^4.0.0"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@istanbuljs/schema": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+      "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@jest/console": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz",
+      "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "jest-message-util": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/core": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz",
+      "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/console": "^27.5.1",
+        "@jest/reporters": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "ansi-escapes": "^4.2.1",
+        "chalk": "^4.0.0",
+        "emittery": "^0.8.1",
+        "exit": "^0.1.2",
+        "graceful-fs": "^4.2.9",
+        "jest-changed-files": "^27.5.1",
+        "jest-config": "^27.5.1",
+        "jest-haste-map": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-resolve-dependencies": "^27.5.1",
+        "jest-runner": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "jest-watcher": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "rimraf": "^3.0.0",
+        "slash": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+      },
+      "peerDependenciesMeta": {
+        "node-notifier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@jest/environment": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz",
+      "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==",
+      "dev": true,
+      "dependencies": {
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "jest-mock": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/fake-timers": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz",
+      "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "@sinonjs/fake-timers": "^8.0.1",
+        "@types/node": "*",
+        "jest-message-util": "^27.5.1",
+        "jest-mock": "^27.5.1",
+        "jest-util": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/globals": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz",
+      "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==",
+      "dev": true,
+      "dependencies": {
+        "@jest/environment": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "expect": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/reporters": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz",
+      "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==",
+      "dev": true,
+      "dependencies": {
+        "@bcoe/v8-coverage": "^0.2.3",
+        "@jest/console": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "collect-v8-coverage": "^1.0.0",
+        "exit": "^0.1.2",
+        "glob": "^7.1.2",
+        "graceful-fs": "^4.2.9",
+        "istanbul-lib-coverage": "^3.0.0",
+        "istanbul-lib-instrument": "^5.1.0",
+        "istanbul-lib-report": "^3.0.0",
+        "istanbul-lib-source-maps": "^4.0.0",
+        "istanbul-reports": "^3.1.3",
+        "jest-haste-map": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-worker": "^27.5.1",
+        "slash": "^3.0.0",
+        "source-map": "^0.6.0",
+        "string-length": "^4.0.1",
+        "terminal-link": "^2.0.0",
+        "v8-to-istanbul": "^8.1.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+      },
+      "peerDependenciesMeta": {
+        "node-notifier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@jest/source-map": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz",
+      "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==",
+      "dev": true,
+      "dependencies": {
+        "callsites": "^3.0.0",
+        "graceful-fs": "^4.2.9",
+        "source-map": "^0.6.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/test-result": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz",
+      "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==",
+      "dev": true,
+      "dependencies": {
+        "@jest/console": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/istanbul-lib-coverage": "^2.0.0",
+        "collect-v8-coverage": "^1.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/test-sequencer": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz",
+      "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/test-result": "^27.5.1",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-runtime": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/transform": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz",
+      "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.1.0",
+        "@jest/types": "^27.5.1",
+        "babel-plugin-istanbul": "^6.1.1",
+        "chalk": "^4.0.0",
+        "convert-source-map": "^1.4.0",
+        "fast-json-stable-stringify": "^2.0.0",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "pirates": "^4.0.4",
+        "slash": "^3.0.0",
+        "source-map": "^0.6.1",
+        "write-file-atomic": "^3.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jest/types": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz",
+      "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==",
+      "dev": true,
+      "dependencies": {
+        "@types/istanbul-lib-coverage": "^2.0.0",
+        "@types/istanbul-reports": "^3.0.0",
+        "@types/node": "*",
+        "@types/yargs": "^16.0.0",
+        "chalk": "^4.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+      "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@jridgewell/set-array": "^1.0.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz",
+      "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew==",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/set-array": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+      "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@jridgewell/source-map": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+      "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.4.11",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz",
+      "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg=="
+    },
+    "node_modules/@jridgewell/trace-mapping": {
+      "version": "0.3.14",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
+      "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "node_modules/@lit/reactive-element": {
+      "version": "1.0.0-rc.4",
+      "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.0-rc.4.tgz",
+      "integrity": "sha512-dJMha+4NFYdpnUJzRrWTFV5Hdp9QHWFuPnaoqonrKl4lGJVnYez9mu8ev9F/5KM47tjAjh22DuRHrdFDHfOijA=="
+    },
+    "node_modules/@material/animation": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/animation/-/animation-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-OjxWJYSRNs4vnPe8NclaNn+TsNc8TR/wHusGtezF5F+wl+5mh+K69BMXAmURtq3idoRg4XaOSC/Ohk1ovD1fMQ==",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/animation/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/base": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/base/-/base-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-vy5SQt+jcwwdRFfBvtpVdpULUBujecVUKOXcopaQoi2XIzI5EBHuR4gPN0cd1yfmVEucD6p2fvVv2FJ3Ngr61w==",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/base/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/button": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/button/-/button-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-DB0MAvdIGWKuFwlQ57hjv7ZuHIioT2mnG7RWtL7ZoCWoY45nCrsbJirmX5zZFipm9gIOJ3YnIkIrUyMVSrDX+g==",
+      "dependencies": {
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/tokens": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/button/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/circular-progress": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/circular-progress/-/circular-progress-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-Gi6Ika8MEZQOT3Qei2NfTj+sRWxCDFjchPM7szNjIKgL2DyH03bHmodQFVcyBFiPWEcWMc/mqVYgGf/XJXs85w==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/progress-indicator": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/circular-progress/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/density": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/density/-/density-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-zOR5wISqPVr8KS/ERNC1jdRV9O832lzclyS9Ea20rDrWfuOiYsQ9bbIk12xWlxpgsn7r9fxQJyd1O2SURoHdRA==",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/density/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/dialog": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/dialog/-/dialog-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-NfQR0fmNS/y2iRAx5YeODLLywBAnSyZI/CL9GUq4NiNj+FeSxe+5bhG1p9NxHeGMjEVrl6fG5L9ql7lqtfQaYQ==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/button": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/icon-button": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/tokens": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/dialog/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/dom": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/dom/-/dom-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-iUpZG6Bb2l/PfNV2Fb/pXfG1p4Bz4PC9A7ATPlKfcU5HioObcnYVc/+Hrtaw8eu28BNIc+VVROtbfpqG/YgKSQ==",
+      "dependencies": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/dom/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/elevation": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/elevation/-/elevation-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-AqN/tsTGGyBzZ7CtoSMBY9bDYvCuUt98EUfiGjZGXcf4HgoHV3Cn/JSLrhru5Cq8Nx6HF6AmHh3dQCfNCQduew==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/elevation/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/feature-targeting": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/feature-targeting/-/feature-targeting-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-CrVoGNu0ym52OPEKy3kgeNL2oSWOCBYbYxSH3GhERxCq5FwGBN+XmK/ZDLFVQlHYy3v8x4TqVEwXviCeumNTxQ==",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/feature-targeting/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/floating-label": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/floating-label/-/floating-label-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-Cp0/LngkW6/uZWbEDTe3Ox143V4kYtxl9twiM3XLKd6a67JHCzneQWFzC0qSg90b3r5O+1zOkT3ZMF2Pbu2Vwg==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/floating-label/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/form-field": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/form-field/-/form-field-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-NCc/o60gwuF28PVMgFkHrKcHxIaCMZK9JRVfoaD0sF2BINYrjaCkFZ+x6AhNjAWLUQMhJMfc+1WXAUE2T85Mug==",
+      "dependencies": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/form-field/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/icon-button": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/icon-button/-/icon-button-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-9P6cjRqKtjE6ML+r5yz0ExU/f2KLdNabHQxmO6RpKd/FnjTyP1NcWqqj8dsvo/DZ7mOtT1MIThgkQDdiMqcYLg==",
+      "dependencies": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/icon-button/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/line-ripple": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-LlyiyxpHNlFt0PZ8Q2tvOPbjNcgm3L7tUebXsM7iGyoKXfj0HwyDI31S0KgtU3Vs5DIK4U4mnRWtoAxtBW6Jfg==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/line-ripple/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/list": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/list/-/list-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-bjHXXk2ZeWxAFs4cJxy5J5A5ClUd3FGjRv/LwCYpsh7Dm7e8kSe8Lw2MWb6FXyF3mDJM6xqN3xXQWOh6UEu5wA==",
+      "dependencies": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/list/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/menu": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/menu/-/menu-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-x59UHoTLvEsPKjFdffrKTgEyc0T4W3m58RsizAmapXr59Uthq8+PTFOkAv9R1PV/ZCzxay7Vx+QcekC4qOr40A==",
+      "dependencies": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/list": "14.0.0-canary.261f2db59.0",
+        "@material/menu-surface": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/menu-surface": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/menu-surface/-/menu-surface-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-zv/fv/W3zdSb+c/p6GNcOqA3+wAc/r8MOtV53WJPLlvZZSpGoTwHUp+GPiNeovfbsTSxN95XOXuVQBEfKEb8vA==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/menu-surface/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/menu/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-base": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-base/-/mwc-base-0.25.3.tgz",
+      "integrity": "sha512-4wvxZ9dhPr0O4jjOHPmFyn77pafe+h1gHPlT9sbQ+ly8NY/fSn/TXn7/PbxgL8g4ZHxMvD3o7PJopg+6cbHp8Q==",
+      "dependencies": {
+        "@lit/reactive-element": "1.0.0-rc.4",
+        "@material/base": "=14.0.0-canary.261f2db59.0",
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-base/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-button": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-button/-/mwc-button-0.25.3.tgz",
+      "integrity": "sha512-usHEKchj9hqetY7n0yebTz1Pk9Z+9W/sNZheFoSaiWQCv9XhtCdKkHH0MXTv8SpwxWuEKUf/XjtyvikGIcIn7w==",
+      "dependencies": {
+        "@material/mwc-icon": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-button/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-checkbox": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-checkbox/-/mwc-checkbox-0.25.3.tgz",
+      "integrity": "sha512-PSh9IAgQK4XiDzBwgclheejkA4cbZ3K9V1JTTl/YVRDD/OLLM+Bh8tbnAg/1kGVlPWOUfDrYCcZ0gg472ca7gw==",
+      "dependencies": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-checkbox/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-circular-progress": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-circular-progress/-/mwc-circular-progress-0.25.3.tgz",
+      "integrity": "sha512-ajgSzfdRfq0/sZg0Z5W/ZpgZwD8Ioj59m5ScCPXXdkRoVHf7+8lsD/2Fh4095GfoYE4PWSkXYVlWsQCx+aJbcA==",
+      "dependencies": {
+        "@material/circular-progress": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/theme": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-circular-progress/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-dialog": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-dialog/-/mwc-dialog-0.25.3.tgz",
+      "integrity": "sha512-UpxAYAzKXO1MW4ezpiYfEQgov08p0J8KDVKqKrMwg7lsZRkAtUMk4YJkM6qmWGqGPqd/cN++42PMPHAISJH3yA==",
+      "dependencies": {
+        "@material/dialog": "=14.0.0-canary.261f2db59.0",
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-button": "^0.25.3",
+        "blocking-elements": "^0.1.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1",
+        "wicg-inert": "^3.0.0"
+      }
+    },
+    "node_modules/@material/mwc-dialog/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-floating-label": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-floating-label/-/mwc-floating-label-0.25.3.tgz",
+      "integrity": "sha512-3uFMi8Y680P0nzP5zih4YuOZJLl/C6Ux9G810Unwo44zblG/ckgJlFiM+T+oR+OH5KM8LbfNlV0ypo7FT5zYJA==",
+      "dependencies": {
+        "@material/floating-label": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-floating-label/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-formfield": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-formfield/-/mwc-formfield-0.25.3.tgz",
+      "integrity": "sha512-JP/ZgsWok0ZVwUQfYgaov0Ocn1zDiiw7Po6q8k/n5tOS67S41XUB/ctiUg1gh00LAM0v3eZAexa9ZmKarviVJA==",
+      "dependencies": {
+        "@material/form-field": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-formfield/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-icon": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-icon/-/mwc-icon-0.25.3.tgz",
+      "integrity": "sha512-36076AWZIRSr8qYOLjuDDkxej/HA0XAosrj7TS1ZeLlUBnLUtbDtvc1S7KSa0hqez7ouzOqGaWK24yoNnTa2OA==",
+      "dependencies": {
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-icon/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-line-ripple": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-line-ripple/-/mwc-line-ripple-0.25.3.tgz",
+      "integrity": "sha512-ANJzSyumb+shBVTIhqF1+YByPU/EpFXxI9CS26qThFqlUDpYXg5xcoZpkMSmZv3Wv/loF1rs2mJfFWOcC6nFnw==",
+      "dependencies": {
+        "@material/line-ripple": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-line-ripple/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-list": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-list/-/mwc-list-0.25.3.tgz",
+      "integrity": "sha512-2T297qVaQsKv+QDNP2ag9g04RLKO1tm2F6BwwqvdbXTsY+LKYOJe2/aSe0kX2tQLayX4ydy2RnTevo9Ld+c+4g==",
+      "dependencies": {
+        "@material/base": "=14.0.0-canary.261f2db59.0",
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/list": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-checkbox": "^0.25.3",
+        "@material/mwc-radio": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-list/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-menu": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-menu/-/mwc-menu-0.25.3.tgz",
+      "integrity": "sha512-jr5R61BfqrJC0lsAI63y4BsEM2eY3n6kiCy2ZnwinmxrfFrS709T/zuSUUW/xG9b9inSku4WjjSkDhPzQrmS3g==",
+      "dependencies": {
+        "@material/menu": "=14.0.0-canary.261f2db59.0",
+        "@material/menu-surface": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-list": "^0.25.3",
+        "@material/shape": "=14.0.0-canary.261f2db59.0",
+        "@material/theme": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-menu/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-notched-outline": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-notched-outline/-/mwc-notched-outline-0.25.3.tgz",
+      "integrity": "sha512-8jvU8GD0Pke+pfTQ0PdXpZmkU3XIHhMVY6AHM/2IQrXHkVZmAm9kbwL7ne3Ao+6f5n+DeXDGd+SG9U6ZZjD7gw==",
+      "dependencies": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/notched-outline": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-notched-outline/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-radio": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-radio/-/mwc-radio-0.25.3.tgz",
+      "integrity": "sha512-SXpVDrsQnz7+2w/kfBxcOJ4P+uJ0RxBd9mCLE7wVyN53gDLkNHqA0npdl2PNpRaaMavVrt27L8wWo5QIT+7zWA==",
+      "dependencies": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "@material/radio": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-radio/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-ripple": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-ripple/-/mwc-ripple-0.25.3.tgz",
+      "integrity": "sha512-G/gt/csxgME6/sAku3GiuB0O2LLvoPWsRTLq/9iABpaGLJjqaKHvNg/IVzNDdF3YZT7EORgR9cBWWl7umA4i4Q==",
+      "dependencies": {
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/ripple": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-ripple/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-select": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-select/-/mwc-select-0.25.3.tgz",
+      "integrity": "sha512-mf1WrsNAW4rDHeVH+AgTPfNHAg70dJdwuIfIBqksAty3pYxnXQ9RjpL4Z/7kLdsGiS44du65vVgmZ63T0ifugQ==",
+      "dependencies": {
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "=14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "=14.0.0-canary.261f2db59.0",
+        "@material/list": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-floating-label": "^0.25.3",
+        "@material/mwc-icon": "^0.25.3",
+        "@material/mwc-line-ripple": "^0.25.3",
+        "@material/mwc-list": "^0.25.3",
+        "@material/mwc-menu": "^0.25.3",
+        "@material/mwc-notched-outline": "^0.25.3",
+        "@material/select": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-select/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-snackbar": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-snackbar/-/mwc-snackbar-0.25.3.tgz",
+      "integrity": "sha512-DJyWQl1rksv502qLQta81YQ3q3iy0GlVQcXZq88nBG9o64070qZW92rfZmiQ63MRwGbdNmrUFZ3QBoClY1JpFg==",
+      "dependencies": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/snackbar": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-snackbar/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-switch": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-switch/-/mwc-switch-0.25.3.tgz",
+      "integrity": "sha512-cjppRf17q70SdtTP0twMAzODJY7ztJFnfDDZKM5N72F4cp2q0VvhIU42hfBCGLIEbXPQBCLG0dxqt2Mo04qCcA==",
+      "dependencies": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "@material/switch": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-switch/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-textarea": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-textarea/-/mwc-textarea-0.25.3.tgz",
+      "integrity": "sha512-u3PkwAL6+2DGr4rxrDAqBPBCwFX40lM8/ZKgQ9mg7xLB6Rhz/5n3Sf5MtMwGSJO0ZU5CGqU3qY9x21S4tM/Xhw==",
+      "dependencies": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-textfield": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-textarea/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/mwc-textfield": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-textfield/-/mwc-textfield-0.25.3.tgz",
+      "integrity": "sha512-stpZ8sEyo2Mb9fG2XCoTc1Kom8oRXZiVI5rU88GtfcBU7nH0em8S4grq9X1mVfUG6Cfi1G/T+avCSIhzbYtr0w==",
+      "dependencies": {
+        "@material/floating-label": "=14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-floating-label": "^0.25.3",
+        "@material/mwc-line-ripple": "^0.25.3",
+        "@material/mwc-notched-outline": "^0.25.3",
+        "@material/textfield": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      }
+    },
+    "node_modules/@material/mwc-textfield/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/notched-outline": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/notched-outline/-/notched-outline-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-gtn+IKAiX2rbfbX3a9aDlfUoKCEYrlAPOZifKXUaZ4UJYMNLzZuAqy7l5Ds30emtqUE22mySTEWqhzK6dePKsA==",
+      "dependencies": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/notched-outline/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/progress-indicator": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/progress-indicator/-/progress-indicator-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-qm+zUMvFYhHuVB2OdgWTO/Dv1hMFEdIT3loX5OJMpvQ66l6rez/3F7blwHkm6W4mfuxRS3zdDdYbP5QdFcuHuA==",
+      "dependencies": {
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/progress-indicator/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/radio": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/radio/-/radio-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-AvrsOqhP8UZ5d58RWgaTmQVlWQRULwk2BXhsEhtxz56CmTsyVM49thNbaNnc/TzuY9Ssxv/L2wYVbR2B3BX9Yw==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/radio/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/ripple": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-3FLCLj8X7KrFfuYBHJg1b7Odb3V/AW7fxk3m1i1zhDnygKmlQ/abVucH1s2qbX3Y+JIiq+5/C5407h9BFtOf+A==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/ripple/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/rtl": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-bVnXBbUsHs57+EXdeFbcwaKy3lT/itI/qTLmJ88ar0qaGEujO1GmESHm3ioqkeo4kQpTfDhBwQGeEi1aDaTdFg==",
+      "dependencies": {
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/rtl/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/select": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/select/-/select-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-r/D3e75H/sg+7iv+dkiyQ9cg8R6koHQJl85/gZqOlHpaQGSH5gSxpVeILkRY+ic6obQTdQCPRvUi9kzUve5zEg==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "14.0.0-canary.261f2db59.0",
+        "@material/list": "14.0.0-canary.261f2db59.0",
+        "@material/menu": "14.0.0-canary.261f2db59.0",
+        "@material/menu-surface": "14.0.0-canary.261f2db59.0",
+        "@material/notched-outline": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/select/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/shape": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/shape/-/shape-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-VjcQltd1uF9ugvLExMy00SMISjy/370o8lsZlb1T+xHyhXHL3UxeuWYLW5Amq6mbx65+c9Df9WmlXXOdebpEkw==",
+      "dependencies": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/shape/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/snackbar": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/snackbar/-/snackbar-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-RLxO0dWBmhU+3y/PCYN0oiQUvzw8cdeFLmiUN9BPn2unwmTPp5nUdaTde7TQ93vRNidyPtDnkEFnflunDCk2Ew==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/button": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/icon-button": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/snackbar/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/switch": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/switch/-/switch-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-WoHxAeTVh43OAwkdC9uWI5caVwCCn0JrxMbPYAonbuoGAn/blXECuDtSpXD3m+05RwSgUHlX9n14nb3SGQMOYw==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/tokens": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/switch/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/textfield": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/textfield/-/textfield-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-KBPgpvvVFBfLx9nc6+wWOS2hJ40JVwh5KBjMoYbiOEFLf0O7SgCAVREHaFAXrPsC8AeTyUipx6TReONIGfMCPQ==",
+      "dependencies": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "14.0.0-canary.261f2db59.0",
+        "@material/notched-outline": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/textfield/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/theme": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/theme/-/theme-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-bUqyFT0QF8Nxx02fekt3CXIfC9DEPOPdo2hjgdtvhrNP+vftbkI2tKZ5/uRUnVA+zqQAOyIl5z6FOMg4fyemCA==",
+      "dependencies": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/theme/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/tokens": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/tokens/-/tokens-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-mgar9gsLv00HTvXIDvNR1vEEXpfKgeWhVTO8a7aWofSNyENNOVc5ImJwBgCAMb5SgLHBi6w8/c1tPzjOewBfCA==",
+      "dependencies": {
+        "@material/elevation": "14.0.0-canary.261f2db59.0"
+      }
+    },
+    "node_modules/@material/touch-target": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/touch-target/-/touch-target-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-xA6TTHN7aOTXg/+c6mQJlogzTD+Sp8WPC5TK8RBXbQxEykGXGW15p+H9pG+rX/gzD5iehnHRBrDUFmAGoskhcQ==",
+      "dependencies": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/touch-target/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@material/typography": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/typography/-/typography-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-WOCdcNkD5KBRAwICcRqWBRG3cDkyrwK5USTNmG0oxnwnZAN7daOpPTdLppVAhadE7faj8d67ON+V9pH7+T62FQ==",
+      "dependencies": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      }
+    },
+    "node_modules/@material/typography/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/@mui/icons-material": {
+      "version": "5.8.4",
+      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.4.tgz",
+      "integrity": "sha512-9Z/vyj2szvEhGWDvb+gG875bOGm8b8rlHBKOD1+nA3PcgC3fV6W1AU6pfOorPeBfH2X4mb9Boe97vHvaSndQvA==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@mui/material": "^5.0.0",
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab": {
+      "version": "5.0.0-alpha.92",
+      "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.92.tgz",
+      "integrity": "sha512-lVBhx6XDKzY9kqZa0iHQyRqRYtAS/EURJsvvkOrIF9xrEwcfyzTJQoR1eWW/7i6FtlEALeKOZEt9usXSpKs8FA==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/base": "5.0.0-alpha.91",
+        "@mui/system": "^5.9.2",
+        "@mui/utils": "^5.9.1",
+        "clsx": "^1.2.1",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.5.0",
+        "@emotion/styled": "^11.3.0",
+        "@mui/material": "^5.0.0",
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        },
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/base": {
+      "version": "5.0.0-alpha.91",
+      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.91.tgz",
+      "integrity": "sha512-/W5amPDz+Lout4FtX5HOyx2Q+YL/EtZciFrx2DDRuUm4M/pWnjfDZAtM+0aqimEvuk3FU+/PuFc7IAyhCSX4Cg==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@emotion/is-prop-valid": "^1.1.3",
+        "@mui/types": "^7.1.5",
+        "@mui/utils": "^5.9.1",
+        "@popperjs/core": "^2.11.5",
+        "clsx": "^1.2.1",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/base/node_modules/@mui/types": {
+      "version": "7.1.5",
+      "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.5.tgz",
+      "integrity": "sha512-HnRXrxgHJYJcT8ZDdDCQIlqk0s0skOKD7eWs9mJgBUu70hyW4iA6Kiv3yspJR474RFH8hysKR65VVSzUSzkuwA==",
+      "peerDependencies": {
+        "@types/react": "*"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/system": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.9.2.tgz",
+      "integrity": "sha512-iOvt9tVeFapHL7f7M6BSIiKGMx6RTRvAmc8ipMnQ/MR5Qsxwnyv7qKtNC/K11Rk13Xx0VPaPAhyvBcsr3KdpHA==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/private-theming": "^5.9.1",
+        "@mui/styled-engine": "^5.8.7",
+        "@mui/types": "^7.1.5",
+        "@mui/utils": "^5.9.1",
+        "clsx": "^1.2.1",
+        "csstype": "^3.1.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.5.0",
+        "@emotion/styled": "^11.3.0",
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        },
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/system/node_modules/@mui/private-theming": {
+      "version": "5.9.1",
+      "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.9.1.tgz",
+      "integrity": "sha512-eIh2IZJInNTdgPLMo9cruzm8UDX5amBBxxsSoNre7lRj3wcsu3TG5OKjIbzkf4VxHHEhdPeNNQyt92k7L78u2A==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/utils": "^5.9.1",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/system/node_modules/@mui/styled-engine": {
+      "version": "5.8.7",
+      "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.8.7.tgz",
+      "integrity": "sha512-tVqtowjbYmiRq+qcqXK731L9eWoL9H8xTRhuTgaDGKdch1zlt4I2UwInUe1w2N9N/u3/jHsFbLcl1Un3uOwpQg==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@emotion/cache": "^11.9.3",
+        "csstype": "^3.1.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.4.1",
+        "@emotion/styled": "^11.3.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/system/node_modules/@mui/types": {
+      "version": "7.1.5",
+      "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.5.tgz",
+      "integrity": "sha512-HnRXrxgHJYJcT8ZDdDCQIlqk0s0skOKD7eWs9mJgBUu70hyW4iA6Kiv3yspJR474RFH8hysKR65VVSzUSzkuwA==",
+      "peerDependencies": {
+        "@types/react": "*"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/lab/node_modules/@mui/utils": {
+      "version": "5.9.1",
+      "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.9.1.tgz",
+      "integrity": "sha512-8+4adOR3xusyJwvbnZxcjqcmbWvl7Og+260ZKIrSvwnFs0aLubL+8MhiceeDDGcmb0bTKxfUgRJ96j32Jb7P+A==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@types/prop-types": "^15.7.5",
+        "@types/react-is": "^16.7.1 || ^17.0.0",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "react": "^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/@mui/lab/node_modules/react-is": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+    },
+    "node_modules/@mui/material": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.9.2.tgz",
+      "integrity": "sha512-FItBuj9bPdVier2g5OBG2HHlQLou4JuH3gdnY43tpJOrCpmWrbDVJZqrSufKJFO00qjvTYaGlJedIu+vXn79qw==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/base": "5.0.0-alpha.91",
+        "@mui/system": "^5.9.2",
+        "@mui/types": "^7.1.5",
+        "@mui/utils": "^5.9.1",
+        "@types/react-transition-group": "^4.4.5",
+        "clsx": "^1.2.1",
+        "csstype": "^3.1.0",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0",
+        "react-transition-group": "^4.4.2"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.5.0",
+        "@emotion/styled": "^11.3.0",
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        },
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/material/node_modules/@mui/base": {
+      "version": "5.0.0-alpha.91",
+      "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.91.tgz",
+      "integrity": "sha512-/W5amPDz+Lout4FtX5HOyx2Q+YL/EtZciFrx2DDRuUm4M/pWnjfDZAtM+0aqimEvuk3FU+/PuFc7IAyhCSX4Cg==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@emotion/is-prop-valid": "^1.1.3",
+        "@mui/types": "^7.1.5",
+        "@mui/utils": "^5.9.1",
+        "@popperjs/core": "^2.11.5",
+        "clsx": "^1.2.1",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/material/node_modules/@mui/system": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.9.2.tgz",
+      "integrity": "sha512-iOvt9tVeFapHL7f7M6BSIiKGMx6RTRvAmc8ipMnQ/MR5Qsxwnyv7qKtNC/K11Rk13Xx0VPaPAhyvBcsr3KdpHA==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/private-theming": "^5.9.1",
+        "@mui/styled-engine": "^5.8.7",
+        "@mui/types": "^7.1.5",
+        "@mui/utils": "^5.9.1",
+        "clsx": "^1.2.1",
+        "csstype": "^3.1.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.5.0",
+        "@emotion/styled": "^11.3.0",
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        },
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/material/node_modules/@mui/system/node_modules/@mui/private-theming": {
+      "version": "5.9.1",
+      "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.9.1.tgz",
+      "integrity": "sha512-eIh2IZJInNTdgPLMo9cruzm8UDX5amBBxxsSoNre7lRj3wcsu3TG5OKjIbzkf4VxHHEhdPeNNQyt92k7L78u2A==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/utils": "^5.9.1",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@types/react": "^17.0.0 || ^18.0.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/material/node_modules/@mui/system/node_modules/@mui/styled-engine": {
+      "version": "5.8.7",
+      "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.8.7.tgz",
+      "integrity": "sha512-tVqtowjbYmiRq+qcqXK731L9eWoL9H8xTRhuTgaDGKdch1zlt4I2UwInUe1w2N9N/u3/jHsFbLcl1Un3uOwpQg==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@emotion/cache": "^11.9.3",
+        "csstype": "^3.1.0",
+        "prop-types": "^15.8.1"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.4.1",
+        "@emotion/styled": "^11.3.0",
+        "react": "^17.0.0 || ^18.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/material/node_modules/@mui/types": {
+      "version": "7.1.5",
+      "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.5.tgz",
+      "integrity": "sha512-HnRXrxgHJYJcT8ZDdDCQIlqk0s0skOKD7eWs9mJgBUu70hyW4iA6Kiv3yspJR474RFH8hysKR65VVSzUSzkuwA==",
+      "peerDependencies": {
+        "@types/react": "*"
+      },
+      "peerDependenciesMeta": {
+        "@types/react": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/material/node_modules/@mui/utils": {
+      "version": "5.9.1",
+      "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.9.1.tgz",
+      "integrity": "sha512-8+4adOR3xusyJwvbnZxcjqcmbWvl7Og+260ZKIrSvwnFs0aLubL+8MhiceeDDGcmb0bTKxfUgRJ96j32Jb7P+A==",
+      "dependencies": {
+        "@babel/runtime": "^7.17.2",
+        "@types/prop-types": "^15.7.5",
+        "@types/react-is": "^16.7.1 || ^17.0.0",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui"
+      },
+      "peerDependencies": {
+        "react": "^17.0.0 || ^18.0.0"
+      }
+    },
+    "node_modules/@mui/material/node_modules/react-is": {
+      "version": "18.2.0",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+      "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "version": "2.11.5",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz",
+      "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@sinonjs/commons": {
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz",
+      "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==",
+      "dev": true,
+      "dependencies": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "node_modules/@sinonjs/fake-timers": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz",
+      "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==",
+      "dev": true,
+      "dependencies": {
+        "@sinonjs/commons": "^1.7.0"
+      }
+    },
+    "node_modules/@testing-library/dom": {
+      "version": "8.11.3",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.3.tgz",
+      "integrity": "sha512-9LId28I+lx70wUiZjLvi1DB/WT2zGOxUh46glrSNMaWVx849kKAluezVzZrXJfTKKoQTmEOutLes/bHg4Bj3aA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^4.2.0",
+        "aria-query": "^5.0.0",
+        "chalk": "^4.1.0",
+        "dom-accessibility-api": "^0.5.9",
+        "lz-string": "^1.4.4",
+        "pretty-format": "^27.0.2"
+      },
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@testing-library/jest-dom": {
+      "version": "5.16.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.2.tgz",
+      "integrity": "sha512-6ewxs1MXWwsBFZXIk4nKKskWANelkdUehchEOokHsN8X7c2eKXGw+77aRV63UU8f/DTSVUPLaGxdrj4lN7D/ug==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.9.2",
+        "@types/testing-library__jest-dom": "^5.9.1",
+        "aria-query": "^5.0.0",
+        "chalk": "^3.0.0",
+        "css": "^3.0.0",
+        "css.escape": "^1.5.1",
+        "dom-accessibility-api": "^0.5.6",
+        "lodash": "^4.17.15",
+        "redent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8",
+        "npm": ">=6",
+        "yarn": ">=1"
+      }
+    },
+    "node_modules/@testing-library/jest-dom/node_modules/chalk": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+      "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@testing-library/jest-dom/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/@testing-library/react": {
+      "version": "12.1.4",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz",
+      "integrity": "sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "@testing-library/dom": "^8.0.0",
+        "@types/react-dom": "*"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/@testing-library/react-hooks": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz",
+      "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "@types/react": ">=16.9.0",
+        "@types/react-dom": ">=16.9.0",
+        "@types/react-test-renderer": ">=16.9.0",
+        "react-error-boundary": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "peerDependencies": {
+        "react": ">=16.9.0",
+        "react-dom": ">=16.9.0",
+        "react-test-renderer": ">=16.9.0"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        },
+        "react-test-renderer": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@testing-library/user-event": {
+      "version": "13.5.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
+      "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "engines": {
+        "node": ">=10",
+        "npm": ">=6"
+      },
+      "peerDependencies": {
+        "@testing-library/dom": ">=7.21.4"
+      }
+    },
+    "node_modules/@tootallnate/once": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
+      "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/@tsconfig/node10": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz",
+      "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/@tsconfig/node12": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz",
+      "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/@tsconfig/node14": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz",
+      "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/@tsconfig/node16": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz",
+      "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/@types/aria-query": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
+      "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
+      "dev": true
+    },
+    "node_modules/@types/babel__core": {
+      "version": "7.1.18",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz",
+      "integrity": "sha512-S7unDjm/C7z2A2R9NzfKCK1I+BAALDtxEmsJBwlB3EzNfb929ykjL++1CK9LO++EIp2fQrC8O+BwjKvz6UeDyQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "node_modules/@types/babel__generator": {
+      "version": "7.6.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz",
+      "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__template": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz",
+      "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "node_modules/@types/babel__traverse": {
+      "version": "7.14.2",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.14.2.tgz",
+      "integrity": "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/types": "^7.3.0"
+      }
+    },
+    "node_modules/@types/eslint": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.0.tgz",
+      "integrity": "sha512-07XlgzX0YJUn4iG1ocY4IX9DzKSmMGUs6ESKlxWhZRaa0fatIWaHWUVapcuGa8r5HFnTqzj+4OCjd5f7EZ/i/A==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/estree": "*",
+        "@types/json-schema": "*"
+      }
+    },
+    "node_modules/@types/eslint-scope": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.1.tgz",
+      "integrity": "sha512-SCFeogqiptms4Fg29WpOTk5nHIzfpKCemSN63ksBQYKTcXoJEmJagV+DhVmbapZzY4/5YaOV1nZwrsU79fFm1g==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/eslint": "*",
+        "@types/estree": "*"
+      }
+    },
+    "node_modules/@types/estree": {
+      "version": "0.0.50",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz",
+      "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@types/graceful-fs": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
+      "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/istanbul-lib-coverage": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+      "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+      "dev": true
+    },
+    "node_modules/@types/istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+      "dev": true,
+      "dependencies": {
+        "@types/istanbul-lib-coverage": "*"
+      }
+    },
+    "node_modules/@types/istanbul-reports": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
+      "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
+      "dev": true,
+      "dependencies": {
+        "@types/istanbul-lib-report": "*"
+      }
+    },
+    "node_modules/@types/jest": {
+      "version": "27.4.1",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz",
+      "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==",
+      "dev": true,
+      "dependencies": {
+        "jest-matcher-utils": "^27.0.0",
+        "pretty-format": "^27.0.0"
+      }
+    },
+    "node_modules/@types/js-cookie": {
+      "version": "2.2.7",
+      "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
+      "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA=="
+    },
+    "node_modules/@types/json-schema": {
+      "version": "7.0.9",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz",
+      "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==",
+      "dev": true
+    },
+    "node_modules/@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
+      "dev": true
+    },
+    "node_modules/@types/luxon": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.7.tgz",
+      "integrity": "sha512-AxiYycfO+/M4VIH0ribSr2iPFC+APewpJIaQSydwVnzorK3mjSFXkA3HmhQidGx44MpwaatFyEkbW/WD4zdDaQ==",
+      "dev": true
+    },
+    "node_modules/@types/minimist": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz",
+      "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@types/node": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.2.tgz",
+      "integrity": "sha512-JepeIUPFDARgIs0zD/SKPgFsJEAF0X5/qO80llx59gOxFTboS9Amv3S+QfB7lqBId5sFXJ99BN0J6zFRvL9dDA==",
+      "dev": true
+    },
+    "node_modules/@types/normalize-package-data": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
+      "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@types/parse-json": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+      "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
+    },
+    "node_modules/@types/prettier": {
+      "version": "2.4.4",
+      "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz",
+      "integrity": "sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA==",
+      "dev": true
+    },
+    "node_modules/@types/prop-types": {
+      "version": "15.7.5",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
+      "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
+    },
+    "node_modules/@types/react": {
+      "version": "17.0.39",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.39.tgz",
+      "integrity": "sha512-UVavlfAxDd/AgAacMa60Azl7ygyQNRwC/DsHZmKgNvPmRR5p70AJ5Q9EAmL2NWOJmeV+vVUI4IAP7GZrN8h8Ug==",
+      "dependencies": {
+        "@types/prop-types": "*",
+        "@types/scheduler": "*",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "17.0.13",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.13.tgz",
+      "integrity": "sha512-wEP+B8hzvy6ORDv1QBhcQia4j6ea4SFIBttHYpXKPFZRviBvknq0FRh3VrIxeXUmsPkwuXVZrVGG7KUVONmXCQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-is": {
+      "version": "17.0.3",
+      "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.3.tgz",
+      "integrity": "sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==",
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-test-renderer": {
+      "version": "17.0.1",
+      "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz",
+      "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==",
+      "dev": true,
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/react-transition-group": {
+      "version": "4.4.5",
+      "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz",
+      "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==",
+      "dependencies": {
+        "@types/react": "*"
+      }
+    },
+    "node_modules/@types/scheduler": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
+      "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
+    },
+    "node_modules/@types/sinonjs__fake-timers": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.3.tgz",
+      "integrity": "sha512-E1dU4fzC9wN2QK2Cr1MLCfyHM8BoNnRFvuf45LYMPNDA+WqbNzC45S4UzPxvp1fFJ1rvSGU0bPvdd35VLmXG8g==",
+      "dev": true
+    },
+    "node_modules/@types/sizzle": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
+      "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
+      "dev": true
+    },
+    "node_modules/@types/stack-utils": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
+      "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
+      "dev": true
+    },
+    "node_modules/@types/testing-library__jest-dom": {
+      "version": "5.14.3",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz",
+      "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==",
+      "dev": true,
+      "dependencies": {
+        "@types/jest": "*"
+      }
+    },
+    "node_modules/@types/trusted-types": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
+      "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg=="
+    },
+    "node_modules/@types/yargs": {
+      "version": "16.0.4",
+      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
+      "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
+      "dev": true,
+      "dependencies": {
+        "@types/yargs-parser": "*"
+      }
+    },
+    "node_modules/@types/yargs-parser": {
+      "version": "21.0.0",
+      "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
+      "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
+      "dev": true
+    },
+    "node_modules/@types/yauzl": {
+      "version": "2.9.2",
+      "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
+      "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==",
+      "dev": true,
+      "optional": true,
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@typescript-eslint/eslint-plugin": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.16.0.tgz",
+      "integrity": "sha512-SJoba1edXvQRMmNI505Uo4XmGbxCK9ARQpkvOd00anxzri9RNQk0DDCxD+LIl+jYhkzOJiOMMKYEHnHEODjdCw==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "5.16.0",
+        "@typescript-eslint/type-utils": "5.16.0",
+        "@typescript-eslint/utils": "5.16.0",
+        "debug": "^4.3.2",
+        "functional-red-black-tree": "^1.0.1",
+        "ignore": "^5.1.8",
+        "regexpp": "^3.2.0",
+        "semver": "^7.3.5",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/parser": "^5.0.0",
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/parser": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.16.0.tgz",
+      "integrity": "sha512-fkDq86F0zl8FicnJtdXakFs4lnuebH6ZADDw6CYQv0UZeIjHvmEw87m9/29nk2Dv5Lmdp0zQ3zDQhiMWQf/GbA==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/scope-manager": "5.16.0",
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/typescript-estree": "5.16.0",
+        "debug": "^4.3.2"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/scope-manager": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.16.0.tgz",
+      "integrity": "sha512-P+Yab2Hovg8NekLIR/mOElCDPyGgFZKhGoZA901Yax6WR6HVeGLbsqJkZ+Cvk5nts/dAlFKm8PfL43UZnWdpIQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/visitor-keys": "5.16.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/type-utils": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.16.0.tgz",
+      "integrity": "sha512-SKygICv54CCRl1Vq5ewwQUJV/8padIWvPgCxlWPGO/OgQLCijY9G7lDu6H+mqfQtbzDNlVjzVWQmeqbLMBLEwQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/utils": "5.16.0",
+        "debug": "^4.3.2",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/types": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.16.0.tgz",
+      "integrity": "sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@typescript-eslint/typescript-estree": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.16.0.tgz",
+      "integrity": "sha512-SE4VfbLWUZl9MR+ngLSARptUv2E8brY0luCdgmUevU6arZRY/KxYoLI/3V/yxaURR8tLRN7bmZtJdgmzLHI6pQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/visitor-keys": "5.16.0",
+        "debug": "^4.3.2",
+        "globby": "^11.0.4",
+        "is-glob": "^4.0.3",
+        "semver": "^7.3.5",
+        "tsutils": "^3.21.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@typescript-eslint/utils": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.16.0.tgz",
+      "integrity": "sha512-iYej2ER6AwmejLWMWzJIHy3nPJeGDuCqf8Jnb+jAQVoPpmWzwQOfa9hWVB8GIQE5gsCv/rfN4T+AYb/V06WseQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/json-schema": "^7.0.9",
+        "@typescript-eslint/scope-manager": "5.16.0",
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/typescript-estree": "5.16.0",
+        "eslint-scope": "^5.1.1",
+        "eslint-utils": "^3.0.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      },
+      "peerDependencies": {
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/@typescript-eslint/visitor-keys": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.16.0.tgz",
+      "integrity": "sha512-jqxO8msp5vZDhikTwq9ubyMHqZ67UIvawohr4qF3KhlpL7gzSjOd+8471H3nh5LyABkaI85laEKKU8SnGUK5/g==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/types": "5.16.0",
+        "eslint-visitor-keys": "^3.0.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/typescript-eslint"
+      }
+    },
+    "node_modules/@vaadin/router": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/@vaadin/router/-/router-1.7.4.tgz",
+      "integrity": "sha512-B4JVtzFVUMlsjuJHNXEMfNZrM4QDrdeOMc6EEigiHYxwF82py6yDdP6SWP0aPoP3f6aQHt51tLWdXSpkKpWf7A==",
+      "dependencies": {
+        "@vaadin/vaadin-usage-statistics": "^2.1.0",
+        "path-to-regexp": "2.4.0"
+      }
+    },
+    "node_modules/@vaadin/vaadin-development-mode-detector": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.4.tgz",
+      "integrity": "sha512-S+PaFrZpK8uBIOnIHxjntTrgumd5ztuCnZww96ydGKXgo9whXfZsbMwDuD/102a/IuPUMyF+dh/n3PbWzJ6igA=="
+    },
+    "node_modules/@vaadin/vaadin-usage-statistics": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.0.tgz",
+      "integrity": "sha512-e81nbqY5zsaYhLJuOVkJkB/Um1pGK5POIqIlTNhUfjeoyGaJ63tiX8+D5n6F+GgVxUTLUarsKa6SKRcQel0AzA==",
+      "hasInstallScript": true,
+      "dependencies": {
+        "@vaadin/vaadin-development-mode-detector": "^2.0.0"
+      }
+    },
+    "node_modules/@webassemblyjs/ast": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
+      "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/helper-numbers": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1"
+      }
+    },
+    "node_modules/@webassemblyjs/floating-point-hex-parser": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz",
+      "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@webassemblyjs/helper-api-error": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz",
+      "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@webassemblyjs/helper-buffer": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz",
+      "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@webassemblyjs/helper-numbers": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz",
+      "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/floating-point-hex-parser": "1.11.1",
+        "@webassemblyjs/helper-api-error": "1.11.1",
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "node_modules/@webassemblyjs/helper-wasm-bytecode": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz",
+      "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@webassemblyjs/helper-wasm-section": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz",
+      "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-buffer": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/wasm-gen": "1.11.1"
+      }
+    },
+    "node_modules/@webassemblyjs/ieee754": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz",
+      "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@xtuc/ieee754": "^1.2.0"
+      }
+    },
+    "node_modules/@webassemblyjs/leb128": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz",
+      "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "node_modules/@webassemblyjs/utf8": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz",
+      "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@webassemblyjs/wasm-edit": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz",
+      "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-buffer": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/helper-wasm-section": "1.11.1",
+        "@webassemblyjs/wasm-gen": "1.11.1",
+        "@webassemblyjs/wasm-opt": "1.11.1",
+        "@webassemblyjs/wasm-parser": "1.11.1",
+        "@webassemblyjs/wast-printer": "1.11.1"
+      }
+    },
+    "node_modules/@webassemblyjs/wasm-gen": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz",
+      "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/ieee754": "1.11.1",
+        "@webassemblyjs/leb128": "1.11.1",
+        "@webassemblyjs/utf8": "1.11.1"
+      }
+    },
+    "node_modules/@webassemblyjs/wasm-opt": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
+      "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-buffer": "1.11.1",
+        "@webassemblyjs/wasm-gen": "1.11.1",
+        "@webassemblyjs/wasm-parser": "1.11.1"
+      }
+    },
+    "node_modules/@webassemblyjs/wasm-parser": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz",
+      "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-api-error": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/ieee754": "1.11.1",
+        "@webassemblyjs/leb128": "1.11.1",
+        "@webassemblyjs/utf8": "1.11.1"
+      }
+    },
+    "node_modules/@webassemblyjs/wast-printer": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz",
+      "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "node_modules/@xobotyi/scrollbar-width": {
+      "version": "1.9.5",
+      "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
+      "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="
+    },
+    "node_modules/@xtuc/ieee754": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+      "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/@xtuc/long": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+      "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/abab": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz",
+      "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==",
+      "dev": true
+    },
+    "node_modules/acorn": {
+      "version": "8.7.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
+      "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-globals": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz",
+      "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^7.1.1",
+        "acorn-walk": "^7.1.1"
+      }
+    },
+    "node_modules/acorn-globals/node_modules/acorn": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+      "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+      "dev": true,
+      "bin": {
+        "acorn": "bin/acorn"
+      },
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/acorn-import-assertions": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.7.6.tgz",
+      "integrity": "sha512-FlVvVFA1TX6l3lp8VjDnYYq7R1nyW6x3svAt4nDgrWQ9SBaSh9CnbwgSUTasgfNfOG5HlM1ehugCvM+hjo56LA==",
+      "dev": true,
+      "peer": true,
+      "peerDependencies": {
+        "acorn": "^8"
+      }
+    },
+    "node_modules/acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "peerDependencies": {
+        "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      }
+    },
+    "node_modules/acorn-walk": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
+      "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "dev": true,
+      "dependencies": {
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6.0.0"
+      }
+    },
+    "node_modules/aggregate-error": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+      "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+      "dev": true,
+      "dependencies": {
+        "clean-stack": "^2.0.0",
+        "indent-string": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/ajv-keywords": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+      "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+      "dev": true,
+      "peer": true,
+      "peerDependencies": {
+        "ajv": "^6.9.1"
+      }
+    },
+    "node_modules/ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/ansi-escapes": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+      "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.21.3"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "dependencies": {
+        "color-convert": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/arch": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
+      "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/arg": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+      "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "node_modules/aria-query": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
+      "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6.0"
+      }
+    },
+    "node_modules/array-includes": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz",
+      "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1",
+        "get-intrinsic": "^1.1.1",
+        "is-string": "^1.0.7"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/array.prototype.flat": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz",
+      "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/array.prototype.flatmap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.5.tgz",
+      "integrity": "sha512-08u6rVyi1Lj7oqWbS9nUxliETrtIROT4XGTA4D/LWGten6E3ocm7cy9SIrmNHOL5XVbVuckUp3X6Xyg8/zpvHA==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/arrify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
+      "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/asn1": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+      "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+      "dev": true,
+      "dependencies": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
+    "node_modules/assert-plus": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+      "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/ast-types-flow": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
+      "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=",
+      "dev": true
+    },
+    "node_modules/astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/async": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+      "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==",
+      "dev": true
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "dev": true
+    },
+    "node_modules/at-least-node": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+      "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/atob": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+      "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+      "dev": true,
+      "bin": {
+        "atob": "bin/atob.js"
+      },
+      "engines": {
+        "node": ">= 4.5.0"
+      }
+    },
+    "node_modules/aws-sign2": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+      "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/aws4": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz",
+      "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
+      "dev": true
+    },
+    "node_modules/axe-core": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz",
+      "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/axobject-query": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
+      "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==",
+      "dev": true
+    },
+    "node_modules/babel-jest": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz",
+      "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==",
+      "dev": true,
+      "dependencies": {
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/babel__core": "^7.1.14",
+        "babel-plugin-istanbul": "^6.1.1",
+        "babel-preset-jest": "^27.5.1",
+        "chalk": "^4.0.0",
+        "graceful-fs": "^4.2.9",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.8.0"
+      }
+    },
+    "node_modules/babel-plugin-istanbul": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+      "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/helper-plugin-utils": "^7.0.0",
+        "@istanbuljs/load-nyc-config": "^1.0.0",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-instrument": "^5.0.4",
+        "test-exclude": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/babel-plugin-jest-hoist": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz",
+      "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/template": "^7.3.3",
+        "@babel/types": "^7.3.3",
+        "@types/babel__core": "^7.0.0",
+        "@types/babel__traverse": "^7.0.6"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/babel-plugin-macros": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz",
+      "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==",
+      "dependencies": {
+        "@babel/runtime": "^7.7.2",
+        "cosmiconfig": "^6.0.0",
+        "resolve": "^1.12.0"
+      }
+    },
+    "node_modules/babel-preset-current-node-syntax": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
+      "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==",
+      "dev": true,
+      "dependencies": {
+        "@babel/plugin-syntax-async-generators": "^7.8.4",
+        "@babel/plugin-syntax-bigint": "^7.8.3",
+        "@babel/plugin-syntax-class-properties": "^7.8.3",
+        "@babel/plugin-syntax-import-meta": "^7.8.3",
+        "@babel/plugin-syntax-json-strings": "^7.8.3",
+        "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3",
+        "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+        "@babel/plugin-syntax-numeric-separator": "^7.8.3",
+        "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+        "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+        "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+        "@babel/plugin-syntax-top-level-await": "^7.8.3"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/babel-preset-jest": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz",
+      "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==",
+      "dev": true,
+      "dependencies": {
+        "babel-plugin-jest-hoist": "^27.5.1",
+        "babel-preset-current-node-syntax": "^1.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "@babel/core": "^7.0.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "node_modules/bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+      "dev": true,
+      "dependencies": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
+    "node_modules/big-integer": {
+      "version": "1.6.51",
+      "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+      "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/blob-util": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz",
+      "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==",
+      "dev": true
+    },
+    "node_modules/blocking-elements": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/blocking-elements/-/blocking-elements-0.1.1.tgz",
+      "integrity": "sha512-/SLWbEzMoVIMZACCyhD/4Ya2M1PWP1qMKuiymowPcI+PdWDARqeARBjhj73kbUBCxEmTZCUu5TAqxtwUO9C1Ig=="
+    },
+    "node_modules/bluebird": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+      "dev": true
+    },
+    "node_modules/brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "dependencies": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "dependencies": {
+        "fill-range": "^7.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/broadcast-channel": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
+      "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
+      "dependencies": {
+        "@babel/runtime": "^7.7.2",
+        "detect-node": "^2.1.0",
+        "js-sha3": "0.8.0",
+        "microseconds": "0.2.0",
+        "nano-time": "1.0.0",
+        "oblivious-set": "1.0.0",
+        "rimraf": "3.0.2",
+        "unload": "2.2.0"
+      }
+    },
+    "node_modules/browser-process-hrtime": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
+      "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==",
+      "dev": true
+    },
+    "node_modules/browserslist": {
+      "version": "4.19.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz",
+      "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==",
+      "dependencies": {
+        "caniuse-lite": "^1.0.30001286",
+        "electron-to-chromium": "^1.4.17",
+        "escalade": "^3.1.1",
+        "node-releases": "^2.0.1",
+        "picocolors": "^1.0.0"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/browserslist"
+      }
+    },
+    "node_modules/bs-logger": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
+      "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
+      "dev": true,
+      "dependencies": {
+        "fast-json-stable-stringify": "2.x"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/bser": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+      "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+      "dev": true,
+      "dependencies": {
+        "node-int64": "^0.4.0"
+      }
+    },
+    "node_modules/buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
+    },
+    "node_modules/cachedir": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz",
+      "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/call-bind": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/camelcase": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.1.tgz",
+      "integrity": "sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/camelcase-keys": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz",
+      "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "camelcase": "^5.3.1",
+        "map-obj": "^4.0.0",
+        "quick-lru": "^4.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/camelcase-keys/node_modules/camelcase": {
+      "version": "5.3.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+      "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001294",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001294.tgz",
+      "integrity": "sha512-LiMlrs1nSKZ8qkNhpUf5KD0Al1KCBE3zaT7OLOwEkagXMEDij98SiOovn9wxVGQpklk9vVC/pUSqgYmkmKOS8g==",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/browserslist"
+      }
+    },
+    "node_modules/caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
+      "dev": true
+    },
+    "node_modules/chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/chalk?sponsor=1"
+      }
+    },
+    "node_modules/chalk/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/char-regex": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+      "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/chart.js": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.1.tgz",
+      "integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==",
+      "peer": true
+    },
+    "node_modules/check-more-types": {
+      "version": "2.24.0",
+      "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz",
+      "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+      "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chrome-trace-event": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
+      "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6.0"
+      }
+    },
+    "node_modules/ci-info": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.2.0.tgz",
+      "integrity": "sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A==",
+      "dev": true
+    },
+    "node_modules/cjs-module-lexer": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz",
+      "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==",
+      "dev": true
+    },
+    "node_modules/clean-stack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+      "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/cli-cursor": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+      "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+      "dev": true,
+      "dependencies": {
+        "restore-cursor": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cli-table3": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz",
+      "integrity": "sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==",
+      "dev": true,
+      "dependencies": {
+        "object-assign": "^4.1.0",
+        "string-width": "^4.2.0"
+      },
+      "engines": {
+        "node": "10.* || >= 12.*"
+      },
+      "optionalDependencies": {
+        "colors": "^1.1.2"
+      }
+    },
+    "node_modules/cli-truncate": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
+      "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
+      "dev": true,
+      "dependencies": {
+        "slice-ansi": "^3.0.0",
+        "string-width": "^4.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "dependencies": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "node_modules/clone-regexp": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-2.2.0.tgz",
+      "integrity": "sha512-beMpP7BOtTipFuW8hrJvREQ2DrRu3BE7by0ZpibtfBA+qfHYvMGTc2Yb1JMYPKg/JUw0CHYvpg796aNTSW9z7Q==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "is-regexp": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/co": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+      "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
+      "dev": true,
+      "engines": {
+        "iojs": ">= 1.0.0",
+        "node": ">= 0.12.0"
+      }
+    },
+    "node_modules/collect-v8-coverage": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
+      "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==",
+      "dev": true
+    },
+    "node_modules/color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "dependencies": {
+        "color-name": "~1.1.4"
+      },
+      "engines": {
+        "node": ">=7.0.0"
+      }
+    },
+    "node_modules/color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "node_modules/colord": {
+      "version": "2.9.2",
+      "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz",
+      "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/colorette": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+      "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+      "dev": true
+    },
+    "node_modules/colors": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+      "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
+      "dev": true,
+      "optional": true,
+      "engines": {
+        "node": ">=0.1.90"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/common-tags": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz",
+      "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "node_modules/convert-source-map": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+      "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+      "dependencies": {
+        "safe-buffer": "~5.1.1"
+      }
+    },
+    "node_modules/convert-source-map/node_modules/safe-buffer": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+      "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+    },
+    "node_modules/copy-to-clipboard": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz",
+      "integrity": "sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==",
+      "dependencies": {
+        "toggle-selection": "^1.0.6"
+      }
+    },
+    "node_modules/core-js": {
+      "version": "3.21.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
+      "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==",
+      "dev": true,
+      "hasInstallScript": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/core-js-pure": {
+      "version": "3.22.2",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.2.tgz",
+      "integrity": "sha512-Lb+/XT4WC4PaCWWtZpNPaXmjiNDUe5CJuUtbkMrIM1kb1T/jJoAIp+bkVP/r5lHzMr+ZAAF8XHp7+my6Ol0ysQ==",
+      "dev": true,
+      "hasInstallScript": true,
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+      "dev": true
+    },
+    "node_modules/cosmiconfig": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+      "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+      "dependencies": {
+        "@types/parse-json": "^4.0.0",
+        "import-fresh": "^3.1.0",
+        "parse-json": "^5.0.0",
+        "path-type": "^4.0.0",
+        "yaml": "^1.7.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/create-require": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+      "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/css": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
+      "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==",
+      "dev": true,
+      "dependencies": {
+        "inherits": "^2.0.4",
+        "source-map": "^0.6.1",
+        "source-map-resolve": "^0.6.0"
+      }
+    },
+    "node_modules/css-functions-list": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.0.1.tgz",
+      "integrity": "sha512-PriDuifDt4u4rkDgnqRCLnjfMatufLmWNfQnGCq34xZwpY3oabwhB9SqRBmuvWUgndbemCFlKqg+nO7C2q0SBw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=12.22"
+      }
+    },
+    "node_modules/css-in-js-utils": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz",
+      "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==",
+      "dependencies": {
+        "hyphenate-style-name": "^1.0.2",
+        "isobject": "^3.0.1"
+      }
+    },
+    "node_modules/css-loader": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.2.0.tgz",
+      "integrity": "sha512-/rvHfYRjIpymZblf49w8jYcRo2y9gj6rV8UroHGmBxKrIyGLokpycyKzp9OkitvqT29ZSpzJ0Ic7SpnJX3sC8g==",
+      "dev": true,
+      "dependencies": {
+        "icss-utils": "^5.1.0",
+        "postcss": "^8.2.15",
+        "postcss-modules-extract-imports": "^3.0.0",
+        "postcss-modules-local-by-default": "^4.0.0",
+        "postcss-modules-scope": "^3.0.0",
+        "postcss-modules-values": "^4.0.0",
+        "postcss-value-parser": "^4.1.0",
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.0.0"
+      }
+    },
+    "node_modules/css-tree": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
+      "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+      "dependencies": {
+        "mdn-data": "2.0.14",
+        "source-map": "^0.6.1"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
+      "dev": true
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/cssom": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz",
+      "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==",
+      "dev": true
+    },
+    "node_modules/cssstyle": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
+      "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
+      "dev": true,
+      "dependencies": {
+        "cssom": "~0.3.6"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/cssstyle/node_modules/cssom": {
+      "version": "0.3.8",
+      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
+      "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
+      "dev": true
+    },
+    "node_modules/csstype": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
+      "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA=="
+    },
+    "node_modules/cypress": {
+      "version": "8.3.1",
+      "resolved": "https://registry.npmjs.org/cypress/-/cypress-8.3.1.tgz",
+      "integrity": "sha512-1v6pfx+/5cXhaT5T6QKOvnkawmEHWHLiVzm3MYMoQN1fkX2Ma1C32STd3jBStE9qT5qPSTILjGzypVRxCBi40g==",
+      "dev": true,
+      "hasInstallScript": true,
+      "dependencies": {
+        "@cypress/request": "^2.88.6",
+        "@cypress/xvfb": "^1.2.4",
+        "@types/node": "^14.14.31",
+        "@types/sinonjs__fake-timers": "^6.0.2",
+        "@types/sizzle": "^2.3.2",
+        "arch": "^2.2.0",
+        "blob-util": "^2.0.2",
+        "bluebird": "^3.7.2",
+        "cachedir": "^2.3.0",
+        "chalk": "^4.1.0",
+        "check-more-types": "^2.24.0",
+        "cli-cursor": "^3.1.0",
+        "cli-table3": "~0.6.0",
+        "commander": "^5.1.0",
+        "common-tags": "^1.8.0",
+        "dayjs": "^1.10.4",
+        "debug": "^4.3.2",
+        "enquirer": "^2.3.6",
+        "eventemitter2": "^6.4.3",
+        "execa": "4.1.0",
+        "executable": "^4.1.1",
+        "extract-zip": "2.0.1",
+        "figures": "^3.2.0",
+        "fs-extra": "^9.1.0",
+        "getos": "^3.2.1",
+        "is-ci": "^3.0.0",
+        "is-installed-globally": "~0.4.0",
+        "lazy-ass": "^1.6.0",
+        "listr2": "^3.8.3",
+        "lodash": "^4.17.21",
+        "log-symbols": "^4.0.0",
+        "minimist": "^1.2.5",
+        "ospath": "^1.2.2",
+        "pretty-bytes": "^5.6.0",
+        "ramda": "~0.27.1",
+        "request-progress": "^3.0.0",
+        "supports-color": "^8.1.1",
+        "tmp": "~0.2.1",
+        "untildify": "^4.0.0",
+        "url": "^0.11.0",
+        "yauzl": "^2.10.0"
+      },
+      "bin": {
+        "cypress": "bin/cypress"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/cypress/node_modules/@types/node": {
+      "version": "14.17.15",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz",
+      "integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA==",
+      "dev": true
+    },
+    "node_modules/cypress/node_modules/commander": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
+      "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/cypress/node_modules/execa": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
+      "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.0",
+        "get-stream": "^5.0.0",
+        "human-signals": "^1.1.1",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.0",
+        "onetime": "^5.1.0",
+        "signal-exit": "^3.0.2",
+        "strip-final-newline": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/cypress/node_modules/get-stream": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+      "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+      "dev": true,
+      "dependencies": {
+        "pump": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/cypress/node_modules/human-signals": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
+      "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.12.0"
+      }
+    },
+    "node_modules/damerau-levenshtein": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+      "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+      "dev": true
+    },
+    "node_modules/dashdash": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+      "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/data-uri-to-buffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
+      "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 12"
+      }
+    },
+    "node_modules/data-urls": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz",
+      "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==",
+      "dev": true,
+      "dependencies": {
+        "abab": "^2.0.3",
+        "whatwg-mimetype": "^2.3.0",
+        "whatwg-url": "^8.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/dayjs": {
+      "version": "1.10.8",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.8.tgz",
+      "integrity": "sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow=="
+    },
+    "node_modules/debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "dependencies": {
+        "ms": "2.1.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "peerDependenciesMeta": {
+        "supports-color": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/debug/node_modules/ms": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+      "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+    },
+    "node_modules/decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/decamelize-keys": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz",
+      "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "decamelize": "^1.1.0",
+        "map-obj": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/decamelize-keys/node_modules/map-obj": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+      "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/decimal.js": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz",
+      "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==",
+      "dev": true
+    },
+    "node_modules/decode-uri-component": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/dedent": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
+      "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=",
+      "dev": true
+    },
+    "node_modules/deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "node_modules/deepmerge": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+      "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/define-properties": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+      "dev": true,
+      "dependencies": {
+        "object-keys": "^1.0.12"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/detect-newline": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
+      "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/detect-node": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+      "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="
+    },
+    "node_modules/diff": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+      "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.3.1"
+      }
+    },
+    "node_modules/diff-sequences": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
+      "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
+      "dev": true,
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "dependencies": {
+        "path-type": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/dom-accessibility-api": {
+      "version": "0.5.13",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz",
+      "integrity": "sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw==",
+      "dev": true
+    },
+    "node_modules/dom-helpers": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+      "dependencies": {
+        "@babel/runtime": "^7.8.7",
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/domexception": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz",
+      "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==",
+      "dev": true,
+      "dependencies": {
+        "webidl-conversions": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/domexception/node_modules/webidl-conversions": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
+      "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ecc-jsbn": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+      "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+      "dev": true,
+      "dependencies": {
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.1.0"
+      }
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.4.29",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.29.tgz",
+      "integrity": "sha512-N2Jbwxo5Rum8G2YXeUxycs1sv4Qme/ry71HG73bv8BvZl+I/4JtRgK/En+ST/Wh/yF1fqvVCY4jZBgMxnhjtBA=="
+    },
+    "node_modules/emittery": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz",
+      "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/emittery?sponsor=1"
+      }
+    },
+    "node_modules/emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "dev": true,
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/enhanced-resolve": {
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz",
+      "integrity": "sha512-F27oB3WuHDzvR2DOGNTaYy0D5o0cnrv8TeI482VM4kYgQd/FT9lUQwuNsJ0oOHtBUq7eiW5ytqzp7nBFknL+GA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-colors": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "dependencies": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "node_modules/error-stack-parser": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.7.tgz",
+      "integrity": "sha512-chLOW0ZGRf4s8raLrDxa5sdkvPec5YdvwbFnqJme4rk0rFajP8mPtrDL1+I+CwrQDCjswDA5sREX7jYQDQs9vA==",
+      "dependencies": {
+        "stackframe": "^1.1.1"
+      }
+    },
+    "node_modules/es-abstract": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+      "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "es-to-primitive": "^1.2.1",
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.1.1",
+        "get-symbol-description": "^1.0.0",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.2",
+        "internal-slot": "^1.0.3",
+        "is-callable": "^1.2.4",
+        "is-negative-zero": "^2.0.1",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.1",
+        "is-string": "^1.0.7",
+        "is-weakref": "^1.0.1",
+        "object-inspect": "^1.11.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.2",
+        "string.prototype.trimend": "^1.0.4",
+        "string.prototype.trimstart": "^1.0.4",
+        "unbox-primitive": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/es-module-lexer": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.7.1.tgz",
+      "integrity": "sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/es-to-primitive": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+      "dev": true,
+      "dependencies": {
+        "is-callable": "^1.1.4",
+        "is-date-object": "^1.0.1",
+        "is-symbol": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.14.21",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.21.tgz",
+      "integrity": "sha512-7WEoNMBJdLN993dr9h0CpFHPRc3yFZD+EAVY9lg6syJJ12gc5fHq8d75QRExuhnMkT2DaRiIKFThRvDWP+fO+A==",
+      "hasInstallScript": true,
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "esbuild-android-arm64": "0.14.21",
+        "esbuild-darwin-64": "0.14.21",
+        "esbuild-darwin-arm64": "0.14.21",
+        "esbuild-freebsd-64": "0.14.21",
+        "esbuild-freebsd-arm64": "0.14.21",
+        "esbuild-linux-32": "0.14.21",
+        "esbuild-linux-64": "0.14.21",
+        "esbuild-linux-arm": "0.14.21",
+        "esbuild-linux-arm64": "0.14.21",
+        "esbuild-linux-mips64le": "0.14.21",
+        "esbuild-linux-ppc64le": "0.14.21",
+        "esbuild-linux-riscv64": "0.14.21",
+        "esbuild-linux-s390x": "0.14.21",
+        "esbuild-netbsd-64": "0.14.21",
+        "esbuild-openbsd-64": "0.14.21",
+        "esbuild-sunos-64": "0.14.21",
+        "esbuild-windows-32": "0.14.21",
+        "esbuild-windows-64": "0.14.21",
+        "esbuild-windows-arm64": "0.14.21"
+      }
+    },
+    "node_modules/esbuild-linux-64": {
+      "version": "0.14.21",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.21.tgz",
+      "integrity": "sha512-edZyNOv1ql+kpmlzdqzzDjRQYls+tSyi4QFi+PdBhATJFUqHsnNELWA9vMSzAaInPOEaVUTA5Ml28XFChcy4DA==",
+      "cpu": [
+        "x64"
+      ],
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/esbuild-sass-plugin": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.2.3.tgz",
+      "integrity": "sha512-9Ih8ZDFu7bUoxtFzZNviWFGm6VnrwZytVw+jybfZgjRLt4NEWmr3NapVKVZhh3qgIPsoTm9B+i8wJ0QsGMbc7Q==",
+      "dependencies": {
+        "esbuild": "^0.14.13",
+        "sass": "^1.49.0"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=",
+      "engines": {
+        "node": ">=0.8.0"
+      }
+    },
+    "node_modules/escodegen": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz",
+      "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==",
+      "dev": true,
+      "dependencies": {
+        "esprima": "^4.0.1",
+        "estraverse": "^5.2.0",
+        "esutils": "^2.0.2",
+        "optionator": "^0.8.1"
+      },
+      "bin": {
+        "escodegen": "bin/escodegen.js",
+        "esgenerate": "bin/esgenerate.js"
+      },
+      "engines": {
+        "node": ">=6.0"
+      },
+      "optionalDependencies": {
+        "source-map": "~0.6.1"
+      }
+    },
+    "node_modules/escodegen/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/eslint": {
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz",
+      "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==",
+      "dev": true,
+      "dependencies": {
+        "@eslint/eslintrc": "^1.2.1",
+        "@humanwhocodes/config-array": "^0.9.2",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.1.1",
+        "eslint-utils": "^3.0.0",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.3.1",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "functional-red-black-tree": "^1.0.1",
+        "glob-parent": "^6.0.1",
+        "globals": "^13.6.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.0.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "regexpp": "^3.2.0",
+        "strip-ansi": "^6.0.1",
+        "strip-json-comments": "^3.1.0",
+        "text-table": "^0.2.0",
+        "v8-compile-cache": "^2.0.3"
+      },
+      "bin": {
+        "eslint": "bin/eslint.js"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/eslint"
+      }
+    },
+    "node_modules/eslint-config-google": {
+      "version": "0.14.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz",
+      "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=5.16.0"
+      }
+    },
+    "node_modules/eslint-config-prettier": {
+      "version": "8.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz",
+      "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==",
+      "dev": true,
+      "bin": {
+        "eslint-config-prettier": "bin/cli.js"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.0.0"
+      }
+    },
+    "node_modules/eslint-import-resolver-node": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
+      "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^3.2.7",
+        "resolve": "^1.20.0"
+      }
+    },
+    "node_modules/eslint-import-resolver-node/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-module-utils": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz",
+      "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^3.2.7",
+        "find-up": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/debug": {
+      "version": "3.2.7",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+      "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+      "dev": true,
+      "dependencies": {
+        "ms": "^2.1.1"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/find-up": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+      "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/locate-path": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+      "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^2.0.0",
+        "path-exists": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/p-limit": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
+      "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/p-locate": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+      "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/p-try": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
+      "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-module-utils/node_modules/path-exists": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+      "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/eslint-plugin-import": {
+      "version": "2.25.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz",
+      "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==",
+      "dev": true,
+      "dependencies": {
+        "array-includes": "^3.1.4",
+        "array.prototype.flat": "^1.2.5",
+        "debug": "^2.6.9",
+        "doctrine": "^2.1.0",
+        "eslint-import-resolver-node": "^0.3.6",
+        "eslint-module-utils": "^2.7.2",
+        "has": "^1.0.3",
+        "is-core-module": "^2.8.0",
+        "is-glob": "^4.0.3",
+        "minimatch": "^3.0.4",
+        "object.values": "^1.1.5",
+        "resolve": "^1.20.0",
+        "tsconfig-paths": "^3.12.0"
+      },
+      "engines": {
+        "node": ">=4"
+      },
+      "peerDependencies": {
+        "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "dev": true,
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/doctrine": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/eslint-plugin-import/node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+      "dev": true
+    },
+    "node_modules/eslint-plugin-jest": {
+      "version": "26.1.3",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-26.1.3.tgz",
+      "integrity": "sha512-Pju+T7MFpo5VFhFlwrkK/9jRUu18r2iugvgyrWOnnGRaVTFFmFXp+xFJpHyqmjjLmGJPKLeEFLVTAxezkApcpQ==",
+      "dev": true,
+      "dependencies": {
+        "@typescript-eslint/utils": "^5.10.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "@typescript-eslint/eslint-plugin": "^5.0.0",
+        "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@typescript-eslint/eslint-plugin": {
+          "optional": true
+        },
+        "jest": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-jsx-a11y": {
+      "version": "6.5.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz",
+      "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.16.3",
+        "aria-query": "^4.2.2",
+        "array-includes": "^3.1.4",
+        "ast-types-flow": "^0.0.7",
+        "axe-core": "^4.3.5",
+        "axobject-query": "^2.2.0",
+        "damerau-levenshtein": "^1.0.7",
+        "emoji-regex": "^9.2.2",
+        "has": "^1.0.3",
+        "jsx-ast-utils": "^3.2.1",
+        "language-tags": "^1.0.5",
+        "minimatch": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependencies": {
+        "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
+      }
+    },
+    "node_modules/eslint-plugin-jsx-a11y/node_modules/aria-query": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
+      "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.10.2",
+        "@babel/runtime-corejs3": "^7.10.2"
+      },
+      "engines": {
+        "node": ">=6.0"
+      }
+    },
+    "node_modules/eslint-plugin-jsx-a11y/node_modules/emoji-regex": {
+      "version": "9.2.2",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+      "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+      "dev": true
+    },
+    "node_modules/eslint-plugin-prettier": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz",
+      "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==",
+      "dev": true,
+      "dependencies": {
+        "prettier-linter-helpers": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      },
+      "peerDependencies": {
+        "eslint": ">=7.28.0",
+        "prettier": ">=2.0.0"
+      },
+      "peerDependenciesMeta": {
+        "eslint-config-prettier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/eslint-plugin-react": {
+      "version": "7.29.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.29.4.tgz",
+      "integrity": "sha512-CVCXajliVh509PcZYRFyu/BoUEz452+jtQJq2b3Bae4v3xBUWPLCmtmBM+ZinG4MzwmxJgJ2M5rMqhqLVn7MtQ==",
+      "dev": true,
+      "dependencies": {
+        "array-includes": "^3.1.4",
+        "array.prototype.flatmap": "^1.2.5",
+        "doctrine": "^2.1.0",
+        "estraverse": "^5.3.0",
+        "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+        "minimatch": "^3.1.2",
+        "object.entries": "^1.1.5",
+        "object.fromentries": "^2.0.5",
+        "object.hasown": "^1.1.0",
+        "object.values": "^1.1.5",
+        "prop-types": "^15.8.1",
+        "resolve": "^2.0.0-next.3",
+        "semver": "^6.3.0",
+        "string.prototype.matchall": "^4.0.6"
+      },
+      "engines": {
+        "node": ">=4"
+      },
+      "peerDependencies": {
+        "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8"
+      }
+    },
+    "node_modules/eslint-plugin-react-hooks": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.5.0.tgz",
+      "integrity": "sha512-8k1gRt7D7h03kd+SAAlzXkQwWK22BnK6GKZG+FJA6BAGy22CFvl8kCIXKpVux0cCxMWDQUPqSok0LKaZ0aOcCw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/doctrine": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+      "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+      "dev": true,
+      "dependencies": {
+        "esutils": "^2.0.2"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/resolve": {
+      "version": "2.0.0-next.3",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
+      "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+      "dev": true,
+      "dependencies": {
+        "is-core-module": "^2.2.0",
+        "path-parse": "^1.0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/eslint-plugin-react/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/eslint-utils": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+      "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+      "dev": true,
+      "dependencies": {
+        "eslint-visitor-keys": "^2.0.0"
+      },
+      "engines": {
+        "node": "^10.0.0 || ^12.0.0 || >= 14.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      },
+      "peerDependencies": {
+        "eslint": ">=5"
+      }
+    },
+    "node_modules/eslint-utils/node_modules/eslint-visitor-keys": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+      "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/eslint-visitor-keys": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+      "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+      "dev": true,
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/eslint/node_modules/escape-string-regexp": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+      "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint/node_modules/eslint-scope": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+      "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+      "dev": true,
+      "dependencies": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/eslint/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/eslint/node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/eslint/node_modules/globals": {
+      "version": "13.13.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz",
+      "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==",
+      "dev": true,
+      "dependencies": {
+        "type-fest": "^0.20.2"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/eslint/node_modules/levn": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+      "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1",
+        "type-check": "~0.4.0"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/eslint/node_modules/optionator": {
+      "version": "0.9.1",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+      "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+      "dev": true,
+      "dependencies": {
+        "deep-is": "^0.1.3",
+        "fast-levenshtein": "^2.0.6",
+        "levn": "^0.4.1",
+        "prelude-ls": "^1.2.1",
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.3"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/eslint/node_modules/prelude-ls": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+      "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/eslint/node_modules/type-check": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+      "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/eslint/node_modules/type-fest": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+      "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/espree": {
+      "version": "9.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz",
+      "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==",
+      "dev": true,
+      "dependencies": {
+        "acorn": "^8.7.0",
+        "acorn-jsx": "^5.3.1",
+        "eslint-visitor-keys": "^3.3.0"
+      },
+      "engines": {
+        "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+      }
+    },
+    "node_modules/esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true,
+      "bin": {
+        "esparse": "bin/esparse.js",
+        "esvalidate": "bin/esvalidate.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.1.0"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/esquery/node_modules/estraverse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+      "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "dependencies": {
+        "estraverse": "^5.2.0"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esrecurse/node_modules/estraverse": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+      "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true,
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/eventemitter2": {
+      "version": "6.4.4",
+      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz",
+      "integrity": "sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw==",
+      "dev": true
+    },
+    "node_modules/events": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.8.x"
+      }
+    },
+    "node_modules/execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "dev": true,
+      "dependencies": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sindresorhus/execa?sponsor=1"
+      }
+    },
+    "node_modules/execall": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz",
+      "integrity": "sha512-0FU2hZ5Hh6iQnarpRtQurM/aAvp3RIbfvgLHrcqJYzhXyV2KFruhuChf9NC6waAhiUR7FFtlugkI4p7f2Fqlow==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "clone-regexp": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/executable": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz",
+      "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==",
+      "dev": true,
+      "dependencies": {
+        "pify": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/exit": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
+      "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/expect": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz",
+      "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "dev": true
+    },
+    "node_modules/extract-zip": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+      "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.1.1",
+        "get-stream": "^5.1.0",
+        "yauzl": "^2.10.0"
+      },
+      "bin": {
+        "extract-zip": "cli.js"
+      },
+      "engines": {
+        "node": ">= 10.17.0"
+      },
+      "optionalDependencies": {
+        "@types/yauzl": "^2.9.1"
+      }
+    },
+    "node_modules/extract-zip/node_modules/get-stream": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+      "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+      "dev": true,
+      "dependencies": {
+        "pump": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/extsprintf": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+      "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
+      "dev": true,
+      "engines": [
+        "node >=0.6.0"
+      ]
+    },
+    "node_modules/fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+    },
+    "node_modules/fast-diff": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
+      "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
+      "dev": true
+    },
+    "node_modules/fast-glob": {
+      "version": "3.2.11",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
+      "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
+      "dev": true,
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "node_modules/fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+      "dev": true
+    },
+    "node_modules/fast-shallow-equal": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
+      "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
+    },
+    "node_modules/fastest-levenshtein": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz",
+      "integrity": "sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/fastest-stable-stringify": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz",
+      "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q=="
+    },
+    "node_modules/fastq": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+      "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+      "dev": true,
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fb-watchman": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz",
+      "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==",
+      "dev": true,
+      "dependencies": {
+        "bser": "2.1.1"
+      }
+    },
+    "node_modules/fd-slicer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+      "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
+      "dev": true,
+      "dependencies": {
+        "pend": "~1.2.0"
+      }
+    },
+    "node_modules/fetch-blob": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz",
+      "integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "paypal",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "dependencies": {
+        "node-domexception": "^1.0.0",
+        "web-streams-polyfill": "^3.0.3"
+      },
+      "engines": {
+        "node": "^12.20 || >= 14.13"
+      }
+    },
+    "node_modules/fetch-mock": {
+      "version": "9.11.0",
+      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
+      "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.0.0",
+        "@babel/runtime": "^7.0.0",
+        "core-js": "^3.0.0",
+        "debug": "^4.1.1",
+        "glob-to-regexp": "^0.4.0",
+        "is-subset": "^0.1.1",
+        "lodash.isequal": "^4.5.0",
+        "path-to-regexp": "^2.2.1",
+        "querystring": "^0.2.0",
+        "whatwg-url": "^6.5.0"
+      },
+      "engines": {
+        "node": ">=4.0.0"
+      },
+      "funding": {
+        "type": "charity",
+        "url": "https://www.justgiving.com/refugee-support-europe"
+      },
+      "peerDependencies": {
+        "node-fetch": "*"
+      },
+      "peerDependenciesMeta": {
+        "node-fetch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fetch-mock-jest": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz",
+      "integrity": "sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ==",
+      "dev": true,
+      "dependencies": {
+        "fetch-mock": "^9.11.0"
+      },
+      "engines": {
+        "node": ">=8.0.0"
+      },
+      "funding": {
+        "type": "charity",
+        "url": "https://www.justgiving.com/refugee-support-europe"
+      },
+      "peerDependencies": {
+        "node-fetch": "*"
+      },
+      "peerDependenciesMeta": {
+        "node-fetch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/fetch-mock/node_modules/tr46": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+      "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/fetch-mock/node_modules/webidl-conversions": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+      "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+      "dev": true
+    },
+    "node_modules/fetch-mock/node_modules/whatwg-url": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
+      "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
+      "dev": true,
+      "dependencies": {
+        "lodash.sortby": "^4.7.0",
+        "tr46": "^1.0.1",
+        "webidl-conversions": "^4.0.2"
+      }
+    },
+    "node_modules/figures": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
+      "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
+      "dev": true,
+      "dependencies": {
+        "escape-string-regexp": "^1.0.5"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "dependencies": {
+        "flat-cache": "^3.0.4"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/find-root": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+      "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
+    },
+    "node_modules/find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "dependencies": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "dependencies": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      },
+      "engines": {
+        "node": "^10.12.0 || >=12.0.0"
+      }
+    },
+    "node_modules/flatted": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+      "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
+      "dev": true
+    },
+    "node_modules/forever-agent": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+      "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
+      "dev": true,
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+      "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+      "dev": true,
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.6",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 0.12"
+      }
+    },
+    "node_modules/formdata-polyfill": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+      "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+      "dev": true,
+      "dependencies": {
+        "fetch-blob": "^3.1.2"
+      },
+      "engines": {
+        "node": ">=12.20.0"
+      }
+    },
+    "node_modules/fs-extra": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+      "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+      "dev": true,
+      "dependencies": {
+        "at-least-node": "^1.0.0",
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+    },
+    "node_modules/functional-red-black-tree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "dev": true
+    },
+    "node_modules/gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true,
+      "engines": {
+        "node": "6.* || 8.* || >= 10.*"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-package-type": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+      "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.0.0"
+      }
+    },
+    "node_modules/get-stdin": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz",
+      "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/get-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/get-symbol-description": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+      "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/getos": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz",
+      "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==",
+      "dev": true,
+      "dependencies": {
+        "async": "^3.2.0"
+      }
+    },
+    "node_modules/getpass": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+      "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "node_modules/glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "dependencies": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/glob-to-regexp": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+      "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+      "dev": true
+    },
+    "node_modules/global-dirs": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz",
+      "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==",
+      "dev": true,
+      "dependencies": {
+        "ini": "2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/global-modules": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
+      "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "global-prefix": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/global-prefix": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz",
+      "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "ini": "^1.3.5",
+        "kind-of": "^6.0.2",
+        "which": "^1.3.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/global-prefix/node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/global-prefix/node_modules/which": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+      "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "which": "bin/which"
+      }
+    },
+    "node_modules/globals": {
+      "version": "11.12.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+      "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "dependencies": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/globjoin": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
+      "integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/graceful-fs": {
+      "version": "4.2.9",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
+      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
+      "dev": true
+    },
+    "node_modules/har-schema": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+      "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/har-validator": {
+      "version": "5.1.5",
+      "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
+      "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
+      "deprecated": "this library is no longer supported",
+      "dev": true,
+      "dependencies": {
+        "ajv": "^6.12.3",
+        "har-schema": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/hard-rejection": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
+      "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/harmony-reflect": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
+      "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
+      "dev": true
+    },
+    "node_modules/has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "dependencies": {
+        "function-bind": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/has-bigints": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+      "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+      "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/history": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
+      "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
+      "dependencies": {
+        "@babel/runtime": "^7.7.6"
+      }
+    },
+    "node_modules/hoist-non-react-statics": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+      "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+      "dependencies": {
+        "react-is": "^16.7.0"
+      }
+    },
+    "node_modules/hoist-non-react-statics/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+    },
+    "node_modules/hosted-git-info": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+      "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/html-encoding-sniffer": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
+      "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==",
+      "dev": true,
+      "dependencies": {
+        "whatwg-encoding": "^1.0.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true
+    },
+    "node_modules/html-tags": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz",
+      "integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/http-proxy-agent": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz",
+      "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==",
+      "dev": true,
+      "dependencies": {
+        "@tootallnate/once": "1",
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/http-signature": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+      "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "^1.0.0",
+        "jsprim": "^1.2.2",
+        "sshpk": "^1.7.0"
+      },
+      "engines": {
+        "node": ">=0.8",
+        "npm": ">=1.3.7"
+      }
+    },
+    "node_modules/https-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
+      "dev": true,
+      "dependencies": {
+        "agent-base": "6",
+        "debug": "4"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.17.0"
+      }
+    },
+    "node_modules/hyphenate-style-name": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
+      "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/icss-utils": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+      "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+      "dev": true,
+      "engines": {
+        "node": "^10 || ^12 || >= 14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/identity-obj-proxy": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
+      "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=",
+      "dev": true,
+      "dependencies": {
+        "harmony-reflect": "^1.4.6"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/ignore": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+      "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4"
+      }
+    },
+    "node_modules/immutable": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
+      "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw=="
+    },
+    "node_modules/import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "dependencies": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/import-lazy": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
+      "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/import-local": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
+      "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==",
+      "dev": true,
+      "dependencies": {
+        "pkg-dir": "^4.2.0",
+        "resolve-cwd": "^3.0.0"
+      },
+      "bin": {
+        "import-local-fixture": "fixtures/cli.js"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.8.19"
+      }
+    },
+    "node_modules/indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "dependencies": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "node_modules/ini": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz",
+      "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/inline-style-prefixer": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.1.tgz",
+      "integrity": "sha512-AsqazZ8KcRzJ9YPN1wMH2aNM7lkWQ8tSPrW5uDk1ziYwiAPWSZnUsC7lfZq+BDqLqz0B4Pho5wscWcJzVvRzDQ==",
+      "dependencies": {
+        "css-in-js-utils": "^2.0.0"
+      }
+    },
+    "node_modules/internal-slot": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+      "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+      "dev": true,
+      "dependencies": {
+        "get-intrinsic": "^1.1.0",
+        "has": "^1.0.3",
+        "side-channel": "^1.0.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
+    },
+    "node_modules/is-bigint": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+      "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+      "dev": true,
+      "dependencies": {
+        "has-bigints": "^1.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-boolean-object": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+      "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-callable": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+      "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-ci": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz",
+      "integrity": "sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ==",
+      "dev": true,
+      "dependencies": {
+        "ci-info": "^3.1.1"
+      },
+      "bin": {
+        "is-ci": "bin.js"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
+      "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+      "dependencies": {
+        "has": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-date-object": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+      "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-generator-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
+      "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-installed-globally": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz",
+      "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==",
+      "dev": true,
+      "dependencies": {
+        "global-dirs": "^3.0.0",
+        "is-path-inside": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-negative-zero": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+      "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/is-number-object": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
+      "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-plain-obj": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
+      "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-plain-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true
+    },
+    "node_modules/is-regex": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-regexp": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-2.1.0.tgz",
+      "integrity": "sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/is-shared-array-buffer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+      "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-string": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+      "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+      "dev": true,
+      "dependencies": {
+        "has-tostringtag": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-subset": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
+      "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
+      "dev": true
+    },
+    "node_modules/is-symbol": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+      "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+      "dev": true,
+      "dependencies": {
+        "has-symbols": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+      "dev": true
+    },
+    "node_modules/is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/is-weakref": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+      "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "node_modules/isobject": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+      "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/isstream": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+      "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
+      "dev": true
+    },
+    "node_modules/istanbul-lib-coverage": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+      "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-instrument": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.1.0.tgz",
+      "integrity": "sha512-czwUz525rkOFDJxfKK6mYfIs9zBKILyrZQxjz3ABhjQXhbhFsSbo1HW/BFcsDnfJYJWA6thRR5/TUY2qs5W99Q==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.12.3",
+        "@babel/parser": "^7.14.7",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-coverage": "^3.2.0",
+        "semver": "^6.3.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-instrument/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+      "dev": true,
+      "dependencies": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^3.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-report/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/istanbul-lib-source-maps": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+      "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+      "dev": true,
+      "dependencies": {
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0",
+        "source-map": "^0.6.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/istanbul-reports": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz",
+      "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==",
+      "dev": true,
+      "dependencies": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/jest": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
+      "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/core": "^27.5.1",
+        "import-local": "^3.0.2",
+        "jest-cli": "^27.5.1"
+      },
+      "bin": {
+        "jest": "bin/jest.js"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+      },
+      "peerDependenciesMeta": {
+        "node-notifier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jest-changed-files": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz",
+      "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "execa": "^5.0.0",
+        "throat": "^6.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-circus": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz",
+      "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/environment": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "co": "^4.6.0",
+        "dedent": "^0.7.0",
+        "expect": "^27.5.1",
+        "is-generator-fn": "^2.0.0",
+        "jest-each": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "pretty-format": "^27.5.1",
+        "slash": "^3.0.0",
+        "stack-utils": "^2.0.3",
+        "throat": "^6.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-cli": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz",
+      "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/core": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "exit": "^0.1.2",
+        "graceful-fs": "^4.2.9",
+        "import-local": "^3.0.2",
+        "jest-config": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "prompts": "^2.0.1",
+        "yargs": "^16.2.0"
+      },
+      "bin": {
+        "jest": "bin/jest.js"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0"
+      },
+      "peerDependenciesMeta": {
+        "node-notifier": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jest-config": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz",
+      "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.8.0",
+        "@jest/test-sequencer": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "babel-jest": "^27.5.1",
+        "chalk": "^4.0.0",
+        "ci-info": "^3.2.0",
+        "deepmerge": "^4.2.2",
+        "glob": "^7.1.1",
+        "graceful-fs": "^4.2.9",
+        "jest-circus": "^27.5.1",
+        "jest-environment-jsdom": "^27.5.1",
+        "jest-environment-node": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "jest-jasmine2": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-runner": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "parse-json": "^5.2.0",
+        "pretty-format": "^27.5.1",
+        "slash": "^3.0.0",
+        "strip-json-comments": "^3.1.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "ts-node": ">=9.0.0"
+      },
+      "peerDependenciesMeta": {
+        "ts-node": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jest-diff": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
+      "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.0.0",
+        "diff-sequences": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-docblock": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz",
+      "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==",
+      "dev": true,
+      "dependencies": {
+        "detect-newline": "^3.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-each": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz",
+      "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "jest-get-type": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-environment-jsdom": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz",
+      "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/environment": "^27.5.1",
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "jest-mock": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jsdom": "^16.6.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-environment-node": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz",
+      "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/environment": "^27.5.1",
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "jest-mock": "^27.5.1",
+        "jest-util": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-get-type": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
+      "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
+      "dev": true,
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-haste-map": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz",
+      "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "@types/graceful-fs": "^4.1.2",
+        "@types/node": "*",
+        "anymatch": "^3.0.3",
+        "fb-watchman": "^2.0.0",
+        "graceful-fs": "^4.2.9",
+        "jest-regex-util": "^27.5.1",
+        "jest-serializer": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-worker": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "walker": "^1.0.7"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "^2.3.2"
+      }
+    },
+    "node_modules/jest-jasmine2": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz",
+      "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/environment": "^27.5.1",
+        "@jest/source-map": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "co": "^4.6.0",
+        "expect": "^27.5.1",
+        "is-generator-fn": "^2.0.0",
+        "jest-each": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "pretty-format": "^27.5.1",
+        "throat": "^6.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-leak-detector": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz",
+      "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==",
+      "dev": true,
+      "dependencies": {
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-matcher-utils": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz",
+      "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.0.0",
+        "jest-diff": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-message-util": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz",
+      "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==",
+      "dev": true,
+      "dependencies": {
+        "@babel/code-frame": "^7.12.13",
+        "@jest/types": "^27.5.1",
+        "@types/stack-utils": "^2.0.0",
+        "chalk": "^4.0.0",
+        "graceful-fs": "^4.2.9",
+        "micromatch": "^4.0.4",
+        "pretty-format": "^27.5.1",
+        "slash": "^3.0.0",
+        "stack-utils": "^2.0.3"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-mock": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz",
+      "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "@types/node": "*"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-pnp-resolver": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
+      "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      },
+      "peerDependencies": {
+        "jest-resolve": "*"
+      },
+      "peerDependenciesMeta": {
+        "jest-resolve": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jest-regex-util": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz",
+      "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==",
+      "dev": true,
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-resolve": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz",
+      "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-pnp-resolver": "^1.2.2",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "resolve": "^1.20.0",
+        "resolve.exports": "^1.1.0",
+        "slash": "^3.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-resolve-dependencies": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz",
+      "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-snapshot": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-runner": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz",
+      "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/console": "^27.5.1",
+        "@jest/environment": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "emittery": "^0.8.1",
+        "graceful-fs": "^4.2.9",
+        "jest-docblock": "^27.5.1",
+        "jest-environment-jsdom": "^27.5.1",
+        "jest-environment-node": "^27.5.1",
+        "jest-haste-map": "^27.5.1",
+        "jest-leak-detector": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-worker": "^27.5.1",
+        "source-map-support": "^0.5.6",
+        "throat": "^6.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-runtime": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz",
+      "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==",
+      "dev": true,
+      "dependencies": {
+        "@jest/environment": "^27.5.1",
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/globals": "^27.5.1",
+        "@jest/source-map": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "cjs-module-lexer": "^1.0.0",
+        "collect-v8-coverage": "^1.0.0",
+        "execa": "^5.0.0",
+        "glob": "^7.1.3",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-mock": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "slash": "^3.0.0",
+        "strip-bom": "^4.0.0"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-serializer": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz",
+      "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "graceful-fs": "^4.2.9"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-snapshot": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz",
+      "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/core": "^7.7.2",
+        "@babel/generator": "^7.7.2",
+        "@babel/plugin-syntax-typescript": "^7.7.2",
+        "@babel/traverse": "^7.7.2",
+        "@babel/types": "^7.0.0",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/babel__traverse": "^7.0.4",
+        "@types/prettier": "^2.1.5",
+        "babel-preset-current-node-syntax": "^1.0.0",
+        "chalk": "^4.0.0",
+        "expect": "^27.5.1",
+        "graceful-fs": "^4.2.9",
+        "jest-diff": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "jest-haste-map": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "natural-compare": "^1.4.0",
+        "pretty-format": "^27.5.1",
+        "semver": "^7.3.2"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-util": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz",
+      "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "ci-info": "^3.2.0",
+        "graceful-fs": "^4.2.9",
+        "picomatch": "^2.2.3"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-validate": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz",
+      "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==",
+      "dev": true,
+      "dependencies": {
+        "@jest/types": "^27.5.1",
+        "camelcase": "^6.2.0",
+        "chalk": "^4.0.0",
+        "jest-get-type": "^27.5.1",
+        "leven": "^3.1.0",
+        "pretty-format": "^27.5.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-watcher": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz",
+      "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==",
+      "dev": true,
+      "dependencies": {
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "ansi-escapes": "^4.2.1",
+        "chalk": "^4.0.0",
+        "jest-util": "^27.5.1",
+        "string-length": "^4.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/jest-worker": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+      "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "*",
+        "merge-stream": "^2.0.0",
+        "supports-color": "^8.0.0"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      }
+    },
+    "node_modules/js-cookie": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
+      "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
+    },
+    "node_modules/js-sha3": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
+      "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
+    },
+    "node_modules/js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "node_modules/js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "dependencies": {
+        "argparse": "^2.0.1"
+      },
+      "bin": {
+        "js-yaml": "bin/js-yaml.js"
+      }
+    },
+    "node_modules/jsbn": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+      "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
+      "dev": true
+    },
+    "node_modules/jsdom": {
+      "version": "16.7.0",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz",
+      "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==",
+      "dev": true,
+      "dependencies": {
+        "abab": "^2.0.5",
+        "acorn": "^8.2.4",
+        "acorn-globals": "^6.0.0",
+        "cssom": "^0.4.4",
+        "cssstyle": "^2.3.0",
+        "data-urls": "^2.0.0",
+        "decimal.js": "^10.2.1",
+        "domexception": "^2.0.1",
+        "escodegen": "^2.0.0",
+        "form-data": "^3.0.0",
+        "html-encoding-sniffer": "^2.0.1",
+        "http-proxy-agent": "^4.0.1",
+        "https-proxy-agent": "^5.0.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "nwsapi": "^2.2.0",
+        "parse5": "6.0.1",
+        "saxes": "^5.0.1",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^4.0.0",
+        "w3c-hr-time": "^1.0.2",
+        "w3c-xmlserializer": "^2.0.0",
+        "webidl-conversions": "^6.1.0",
+        "whatwg-encoding": "^1.0.5",
+        "whatwg-mimetype": "^2.3.0",
+        "whatwg-url": "^8.5.0",
+        "ws": "^7.4.6",
+        "xml-name-validator": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "peerDependencies": {
+        "canvas": "^2.5.0"
+      },
+      "peerDependenciesMeta": {
+        "canvas": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsdom/node_modules/form-data": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+      "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+      "dev": true,
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/jsdom/node_modules/tough-cookie": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
+      "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==",
+      "dev": true,
+      "dependencies": {
+        "psl": "^1.1.33",
+        "punycode": "^2.1.1",
+        "universalify": "^0.1.2"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jsdom/node_modules/universalify": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+      "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 4.0.0"
+      }
+    },
+    "node_modules/jsdom/node_modules/ws": {
+      "version": "7.5.7",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz",
+      "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8.3.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": "^5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/jsesc": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==",
+      "bin": {
+        "jsesc": "bin/jsesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/json-parse-better-errors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+      "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
+    },
+    "node_modules/json-schema": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+      "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+      "dev": true
+    },
+    "node_modules/json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "node_modules/json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+      "dev": true
+    },
+    "node_modules/json-stringify-safe": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
+      "dev": true
+    },
+    "node_modules/json5": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
+      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
+      "dependencies": {
+        "minimist": "^1.2.5"
+      },
+      "bin": {
+        "json5": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dev": true,
+      "dependencies": {
+        "universalify": "^2.0.0"
+      },
+      "optionalDependencies": {
+        "graceful-fs": "^4.1.6"
+      }
+    },
+    "node_modules/jsprim": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
+      "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
+      "dev": true,
+      "dependencies": {
+        "assert-plus": "1.0.0",
+        "extsprintf": "1.3.0",
+        "json-schema": "0.4.0",
+        "verror": "1.10.0"
+      },
+      "engines": {
+        "node": ">=0.6.0"
+      }
+    },
+    "node_modules/jsx-ast-utils": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz",
+      "integrity": "sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==",
+      "dev": true,
+      "dependencies": {
+        "array-includes": "^3.1.3",
+        "object.assign": "^4.1.2"
+      },
+      "engines": {
+        "node": ">=4.0"
+      }
+    },
+    "node_modules/kind-of": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/kleur": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+      "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/klona": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+      "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/known-css-properties": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.25.0.tgz",
+      "integrity": "sha512-b0/9J1O9Jcyik1GC6KC42hJ41jKwdO/Mq8Mdo5sYN+IuRTXs2YFHZC3kZSx6ueusqa95x3wLYe/ytKjbAfGixA==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/language-subtag-registry": {
+      "version": "0.3.21",
+      "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz",
+      "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==",
+      "dev": true
+    },
+    "node_modules/language-tags": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz",
+      "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=",
+      "dev": true,
+      "dependencies": {
+        "language-subtag-registry": "~0.3.2"
+      }
+    },
+    "node_modules/lazy-ass": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
+      "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=",
+      "dev": true,
+      "engines": {
+        "node": "> 0.8"
+      }
+    },
+    "node_modules/leven": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+      "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/levn": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
+    },
+    "node_modules/listr2": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.12.0.tgz",
+      "integrity": "sha512-DLaOIhIBXxSDGfAuGyQPsQs6XPIJrUE1MaNYBq8aUS3bulSAEl9RMNNuRbfdxonTizL5ztAYvCZKKnP3gFSvYg==",
+      "dev": true,
+      "dependencies": {
+        "cli-truncate": "^2.1.0",
+        "colorette": "^1.2.2",
+        "log-update": "^4.0.0",
+        "p-map": "^4.0.0",
+        "rxjs": "^6.6.7",
+        "through": "^2.3.8",
+        "wrap-ansi": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "enquirer": ">= 2.3.0 < 3"
+      }
+    },
+    "node_modules/lit": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/lit/-/lit-2.0.2.tgz",
+      "integrity": "sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==",
+      "dependencies": {
+        "@lit/reactive-element": "^1.0.0",
+        "lit-element": "^3.0.0",
+        "lit-html": "^2.0.0"
+      }
+    },
+    "node_modules/lit-element": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.1.tgz",
+      "integrity": "sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==",
+      "dependencies": {
+        "lit-html": "^1.1.1"
+      }
+    },
+    "node_modules/lit-html": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz",
+      "integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA=="
+    },
+    "node_modules/lit/node_modules/@lit/reactive-element": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.2.tgz",
+      "integrity": "sha512-oz3d3MKjQ2tXynQgyaQaMpGTDNyNDeBdo6dXf1AbjTwhA1IRINHmA7kSaVYv9ttKweNkEoNqp9DqteDdgWzPEg=="
+    },
+    "node_modules/lit/node_modules/lit-element": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.0.2.tgz",
+      "integrity": "sha512-9vTJ47D2DSE4Jwhle7aMzEwO2ZcOPRikqfT3CVG7Qol2c9/I4KZwinZNW5Xv8hNm+G/enSSfIwqQhIXi6ioAUg==",
+      "dependencies": {
+        "@lit/reactive-element": "^1.0.0",
+        "lit-html": "^2.0.0"
+      }
+    },
+    "node_modules/lit/node_modules/lit-html": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.0.2.tgz",
+      "integrity": "sha512-dON7Zg8btb14/fWohQLQBdSgkoiQA4mIUy87evmyJHtxRq7zS6LlC32bT5EPWiof5PUQaDpF45v2OlrxHA5Clg==",
+      "dependencies": {
+        "@types/trusted-types": "^2.0.2"
+      }
+    },
+    "node_modules/loader-runner": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz",
+      "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6.11.5"
+      }
+    },
+    "node_modules/locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "dependencies": {
+        "p-locate": "^4.1.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "node_modules/lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
+      "dev": true
+    },
+    "node_modules/lodash.memoize": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+      "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=",
+      "dev": true
+    },
+    "node_modules/lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "node_modules/lodash.once": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+      "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=",
+      "dev": true
+    },
+    "node_modules/lodash.sortby": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+      "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",
+      "dev": true
+    },
+    "node_modules/lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "dependencies": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/log-update": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
+      "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
+      "dev": true,
+      "dependencies": {
+        "ansi-escapes": "^4.3.0",
+        "cli-cursor": "^3.1.0",
+        "slice-ansi": "^4.0.0",
+        "wrap-ansi": "^6.2.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/log-update/node_modules/slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+      }
+    },
+    "node_modules/log-update/node_modules/wrap-ansi": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+      "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "dependencies": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      },
+      "bin": {
+        "loose-envify": "cli.js"
+      }
+    },
+    "node_modules/lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "dependencies": {
+        "yallist": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/luxon": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.1.1.tgz",
+      "integrity": "sha512-6VQVNw7+kQu3hL1ZH5GyOhnk8uZm21xS7XJ/6vDZaFNcb62dpFDKcH8TI5NkoZOdMRxr7af7aYGrJlE/Wv0i1w==",
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/lz-string": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
+      "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
+      "dev": true,
+      "bin": {
+        "lz-string": "bin/bin.js"
+      }
+    },
+    "node_modules/make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "dev": true,
+      "dependencies": {
+        "semver": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/make-dir/node_modules/semver": {
+      "version": "6.3.0",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+      "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+      "dev": true,
+      "bin": {
+        "semver": "bin/semver.js"
+      }
+    },
+    "node_modules/make-error": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+      "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+      "dev": true
+    },
+    "node_modules/makeerror": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+      "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+      "dev": true,
+      "dependencies": {
+        "tmpl": "1.0.5"
+      }
+    },
+    "node_modules/map-obj": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
+      "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/match-sorter": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
+      "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
+      "dependencies": {
+        "@babel/runtime": "^7.12.5",
+        "remove-accents": "0.4.2"
+      }
+    },
+    "node_modules/mathml-tag-names": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz",
+      "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==",
+      "dev": true,
+      "peer": true,
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/wooorm"
+      }
+    },
+    "node_modules/mdn-data": {
+      "version": "2.0.14",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
+      "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="
+    },
+    "node_modules/meow": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
+      "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/minimist": "^1.2.0",
+        "camelcase-keys": "^6.2.2",
+        "decamelize": "^1.2.0",
+        "decamelize-keys": "^1.1.0",
+        "hard-rejection": "^2.1.0",
+        "minimist-options": "4.1.0",
+        "normalize-package-data": "^3.0.0",
+        "read-pkg-up": "^7.0.1",
+        "redent": "^3.0.0",
+        "trim-newlines": "^3.0.0",
+        "type-fest": "^0.18.0",
+        "yargs-parser": "^20.2.3"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/meow/node_modules/type-fest": {
+      "version": "0.18.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
+      "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "dev": true,
+      "dependencies": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/microseconds": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
+      "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
+    },
+    "node_modules/mime-db": {
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
+      "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.32",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz",
+      "integrity": "sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==",
+      "dev": true,
+      "dependencies": {
+        "mime-db": "1.49.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/min-indent": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "dependencies": {
+        "brace-expansion": "^1.1.7"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
+    },
+    "node_modules/minimist-options": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
+      "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "arrify": "^1.0.1",
+        "is-plain-obj": "^1.1.0",
+        "kind-of": "^6.0.3"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "node_modules/nano-css": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.3.4.tgz",
+      "integrity": "sha512-wfcviJB6NOxDIDfr7RFn/GlaN7I/Bhe4d39ZRCJ3xvZX60LVe2qZ+rDqM49nm4YT81gAjzS+ZklhKP/Gnfnubg==",
+      "dependencies": {
+        "css-tree": "^1.1.2",
+        "csstype": "^3.0.6",
+        "fastest-stable-stringify": "^2.0.2",
+        "inline-style-prefixer": "^6.0.0",
+        "rtl-css-js": "^1.14.0",
+        "sourcemap-codec": "^1.4.8",
+        "stacktrace-js": "^2.0.2",
+        "stylis": "^4.0.6"
+      },
+      "peerDependencies": {
+        "react": "*",
+        "react-dom": "*"
+      }
+    },
+    "node_modules/nano-time": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
+      "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
+      "dependencies": {
+        "big-integer": "^1.6.16"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+      "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "dev": true
+    },
+    "node_modules/neo-async": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+      "dev": true
+    },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.3.tgz",
+      "integrity": "sha512-AXP18u4pidSZ1xYXRDPY/8jdv3RAozIt/WLNR/MBGZAz+xjtlr90RvCnsvHQRiXyWliZF/CpytExp32UU67/SA==",
+      "dev": true,
+      "dependencies": {
+        "data-uri-to-buffer": "^4.0.0",
+        "fetch-blob": "^3.1.4",
+        "formdata-polyfill": "^4.0.10"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/node-fetch"
+      }
+    },
+    "node_modules/node-int64": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+      "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=",
+      "dev": true
+    },
+    "node_modules/node-releases": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz",
+      "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA=="
+    },
+    "node_modules/normalize-package-data": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz",
+      "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "hosted-git-info": "^4.0.1",
+        "is-core-module": "^2.5.0",
+        "semver": "^7.3.4",
+        "validate-npm-package-license": "^3.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/normalize-selector": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz",
+      "integrity": "sha1-0LFF62kRicY6eNIB3E/bEpPvDAM=",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "dev": true,
+      "dependencies": {
+        "path-key": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/nwsapi": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz",
+      "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==",
+      "dev": true
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+      "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+      "dev": true,
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.assign": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+      "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "has-symbols": "^1.0.1",
+        "object-keys": "^1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.entries": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+      "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/object.fromentries": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+      "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.hasown": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.0.tgz",
+      "integrity": "sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg==",
+      "dev": true,
+      "dependencies": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/object.values": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+      "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/oblivious-set": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
+      "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dev": true,
+      "dependencies": {
+        "mimic-fn": "^2.1.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/optionator": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
+      "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
+      "dev": true,
+      "dependencies": {
+        "deep-is": "~0.1.3",
+        "fast-levenshtein": "~2.0.6",
+        "levn": "~0.3.0",
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2",
+        "word-wrap": "~1.2.3"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/ospath": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz",
+      "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=",
+      "dev": true
+    },
+    "node_modules/p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "yocto-queue": "^0.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "dependencies": {
+        "p-limit": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/p-locate/node_modules/p-limit": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+      "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+      "dev": true,
+      "dependencies": {
+        "p-try": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-map": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+      "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+      "dev": true,
+      "dependencies": {
+        "aggregate-error": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "dependencies": {
+        "callsites": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "dependencies": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+      "dev": true
+    },
+    "node_modules/path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
+    },
+    "node_modules/path-to-regexp": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz",
+      "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w=="
+    },
+    "node_modules/path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/pend": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+      "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
+      "dev": true
+    },
+    "node_modules/performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+      "dev": true
+    },
+    "node_modules/picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pirates": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
+      "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/pkg-dir": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+      "dev": true,
+      "dependencies": {
+        "find-up": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.4.13",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz",
+      "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        }
+      ],
+      "dependencies": {
+        "nanoid": "^3.3.3",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-media-query-parser": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
+      "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=",
+      "dev": true
+    },
+    "node_modules/postcss-modules-extract-imports": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+      "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+      "dev": true,
+      "engines": {
+        "node": "^10 || ^12 || >= 14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/postcss-modules-local-by-default": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+      "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+      "dev": true,
+      "dependencies": {
+        "icss-utils": "^5.0.0",
+        "postcss-selector-parser": "^6.0.2",
+        "postcss-value-parser": "^4.1.0"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >= 14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/postcss-modules-scope": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+      "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+      "dev": true,
+      "dependencies": {
+        "postcss-selector-parser": "^6.0.4"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >= 14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/postcss-modules-values": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+      "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+      "dev": true,
+      "dependencies": {
+        "icss-utils": "^5.0.0"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >= 14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/postcss-resolve-nested-selector": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz",
+      "integrity": "sha1-Kcy8fDfe36wwTp//C/FZaz9qDk4=",
+      "dev": true
+    },
+    "node_modules/postcss-safe-parser": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz",
+      "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=12.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/postcss/"
+      },
+      "peerDependencies": {
+        "postcss": "^8.3.3"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+      "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+      "dev": true,
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-sorting": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-7.0.1.tgz",
+      "integrity": "sha512-iLBFYz6VRYyLJEJsBJ8M3TCqNcckVzz4wFounSc5Oez35ogE/X+aoC5fFu103Ot7NyvjU3/xqIXn93Gp3kJk4g==",
+      "dev": true,
+      "peerDependencies": {
+        "postcss": "^8.3.9"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true
+    },
+    "node_modules/prelude-ls": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
+      "dev": true,
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/prettier": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.1.tgz",
+      "integrity": "sha512-8UVbTBYGwN37Bs9LERmxCPjdvPxlEowx2urIL6urHzdb3SDq4B/Z6xLFCblrSnE4iKWcS6ziJ3aOYrc1kz/E2A==",
+      "dev": true,
+      "peer": true,
+      "bin": {
+        "prettier": "bin-prettier.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "funding": {
+        "url": "https://github.com/prettier/prettier?sponsor=1"
+      }
+    },
+    "node_modules/prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "dependencies": {
+        "fast-diff": "^1.1.2"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/pretty-bytes": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+      "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/pretty-format": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1",
+        "ansi-styles": "^5.0.0",
+        "react-is": "^17.0.1"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      }
+    },
+    "node_modules/pretty-format/node_modules/ansi-styles": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+      "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+      }
+    },
+    "node_modules/prompts": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+      "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+      "dev": true,
+      "dependencies": {
+        "kleur": "^3.0.3",
+        "sisteransi": "^1.0.5"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "dependencies": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      }
+    },
+    "node_modules/prop-types/node_modules/react-is": {
+      "version": "16.13.1",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+      "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+    },
+    "node_modules/psl": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
+      "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
+      "dev": true
+    },
+    "node_modules/pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "dev": true,
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.5.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+      "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/querystring": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+      "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
+      "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+      "dev": true,
+      "engines": {
+        "node": ">=0.4.x"
+      }
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/quick-lru": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz",
+      "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ramda": {
+      "version": "0.27.1",
+      "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz",
+      "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==",
+      "dev": true
+    },
+    "node_modules/randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "node_modules/react": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
+      "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-chartjs-2": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.0.1.tgz",
+      "integrity": "sha512-q8bgWzKoFvBvD7YcjT/hXG8jt55TaMAuJ1dmI3tKFJ7CijUWYz4pIfOhkTI6PBTwqu/pmeWsClBRd/7HiWzN1g==",
+      "peerDependencies": {
+        "chart.js": "^3.5.0",
+        "react": "^16.8.0 || ^17.0.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
+      "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "scheduler": "^0.20.2"
+      },
+      "peerDependencies": {
+        "react": "17.0.2"
+      }
+    },
+    "node_modules/react-error-boundary": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+      "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+      "dev": true,
+      "dependencies": {
+        "@babel/runtime": "^7.12.5"
+      },
+      "engines": {
+        "node": ">=10",
+        "npm": ">=6"
+      },
+      "peerDependencies": {
+        "react": ">=16.13.1"
+      }
+    },
+    "node_modules/react-is": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+      "dev": true
+    },
+    "node_modules/react-query": {
+      "version": "3.34.16",
+      "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.16.tgz",
+      "integrity": "sha512-7FvBvjgEM4YQ8nPfmAr+lJfbW95uyW/TVjFoi2GwCkF33/S8ajx45tuPHPFGWs4qYwPy1mzwxD4IQfpUDrefNQ==",
+      "dependencies": {
+        "@babel/runtime": "^7.5.5",
+        "broadcast-channel": "^3.4.1",
+        "match-sorter": "^6.0.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/tannerlinsley"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0"
+      },
+      "peerDependenciesMeta": {
+        "react-dom": {
+          "optional": true
+        },
+        "react-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/react-router": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.2.tgz",
+      "integrity": "sha512-/MbxyLzd7Q7amp4gDOGaYvXwhEojkJD5BtExkuKmj39VEE0m3l/zipf6h2WIB2jyAO0lI6NGETh4RDcktRm4AQ==",
+      "dependencies": {
+        "history": "^5.2.0"
+      },
+      "peerDependencies": {
+        "react": ">=16.8"
+      }
+    },
+    "node_modules/react-router-dom": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.2.tgz",
+      "integrity": "sha512-AtYEsAST7bDD4dLSQHDnk/qxWLJdad5t1HFa1qJyUrCeGgEuCSw0VB/27ARbF9Fi/W5598ujvJOm3ujUCVzuYQ==",
+      "dependencies": {
+        "history": "^5.2.0",
+        "react-router": "6.2.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.8",
+        "react-dom": ">=16.8"
+      }
+    },
+    "node_modules/react-shallow-renderer": {
+      "version": "16.14.1",
+      "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz",
+      "integrity": "sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg==",
+      "dev": true,
+      "dependencies": {
+        "object-assign": "^4.1.1",
+        "react-is": "^16.12.0 || ^17.0.0"
+      },
+      "peerDependencies": {
+        "react": "^16.0.0 || ^17.0.0"
+      }
+    },
+    "node_modules/react-test-renderer": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
+      "integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
+      "dev": true,
+      "dependencies": {
+        "object-assign": "^4.1.1",
+        "react-is": "^17.0.2",
+        "react-shallow-renderer": "^16.13.1",
+        "scheduler": "^0.20.2"
+      },
+      "peerDependencies": {
+        "react": "17.0.2"
+      }
+    },
+    "node_modules/react-transition-group": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
+      "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
+      "dependencies": {
+        "@babel/runtime": "^7.5.5",
+        "dom-helpers": "^5.0.1",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2"
+      },
+      "peerDependencies": {
+        "react": ">=16.6.0",
+        "react-dom": ">=16.6.0"
+      }
+    },
+    "node_modules/react-universal-interface": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
+      "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
+      "peerDependencies": {
+        "react": "*",
+        "tslib": "*"
+      }
+    },
+    "node_modules/react-use": {
+      "version": "17.3.2",
+      "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.3.2.tgz",
+      "integrity": "sha512-bj7OD0/1wL03KyWmzFXAFe425zziuTf7q8olwCYBfOeFHY1qfO1FAMjROQLsLZYwG4Rx63xAfb7XAbBrJsZmEw==",
+      "dependencies": {
+        "@types/js-cookie": "^2.2.6",
+        "@xobotyi/scrollbar-width": "^1.9.5",
+        "copy-to-clipboard": "^3.3.1",
+        "fast-deep-equal": "^3.1.3",
+        "fast-shallow-equal": "^1.0.0",
+        "js-cookie": "^2.2.1",
+        "nano-css": "^5.3.1",
+        "react-universal-interface": "^0.6.2",
+        "resize-observer-polyfill": "^1.5.1",
+        "screenfull": "^5.1.0",
+        "set-harmonic-interval": "^1.0.1",
+        "throttle-debounce": "^3.0.1",
+        "ts-easing": "^0.2.0",
+        "tslib": "^2.1.0"
+      },
+      "peerDependencies": {
+        "react": "^16.8.0  || ^17.0.0",
+        "react-dom": "^16.8.0  || ^17.0.0"
+      }
+    },
+    "node_modules/react-use/node_modules/tslib": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+      "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+    },
+    "node_modules/read-pkg": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
+      "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/normalize-package-data": "^2.4.0",
+        "normalize-package-data": "^2.5.0",
+        "parse-json": "^5.0.0",
+        "type-fest": "^0.6.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg-up": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+      "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "find-up": "^4.1.0",
+        "read-pkg": "^5.2.0",
+        "type-fest": "^0.8.1"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/read-pkg-up/node_modules/type-fest": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+      "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/read-pkg/node_modules/hosted-git-info": {
+      "version": "2.8.9",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+      "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/read-pkg/node_modules/normalize-package-data": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+      "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "hosted-git-info": "^2.1.4",
+        "resolve": "^1.10.0",
+        "semver": "2 || 3 || 4 || 5",
+        "validate-npm-package-license": "^3.0.1"
+      }
+    },
+    "node_modules/read-pkg/node_modules/semver": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+      "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+      "dev": true,
+      "peer": true,
+      "bin": {
+        "semver": "bin/semver"
+      }
+    },
+    "node_modules/read-pkg/node_modules/type-fest": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
+      "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/redent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+      "dev": true,
+      "dependencies": {
+        "indent-string": "^4.0.0",
+        "strip-indent": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/regenerator-runtime": {
+      "version": "0.13.9",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
+      "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
+    },
+    "node_modules/regexp.prototype.flags": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
+      "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/regexpp": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+      "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/mysticatea"
+      }
+    },
+    "node_modules/remove-accents": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+      "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+    },
+    "node_modules/request-progress": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz",
+      "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=",
+      "dev": true,
+      "dependencies": {
+        "throttleit": "^1.0.0"
+      }
+    },
+    "node_modules/require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
+    "node_modules/resolve": {
+      "version": "1.22.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",
+      "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==",
+      "dependencies": {
+        "is-core-module": "^2.8.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/resolve-cwd": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
+      "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
+      "dev": true,
+      "dependencies": {
+        "resolve-from": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/resolve-cwd/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/resolve.exports": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz",
+      "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/restore-cursor": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+      "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+      "dev": true,
+      "dependencies": {
+        "onetime": "^5.1.0",
+        "signal-exit": "^3.0.2"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true,
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "dependencies": {
+        "glob": "^7.1.3"
+      },
+      "bin": {
+        "rimraf": "bin.js"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/rtl-css-js": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.15.0.tgz",
+      "integrity": "sha512-99Cu4wNNIhrI10xxUaABHsdDqzalrSRTie4GeCmbGVuehm4oj+fIy8fTzB+16pmKe8Bv9rl+hxIBez6KxExTew==",
+      "dependencies": {
+        "@babel/runtime": "^7.1.2"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "node_modules/rxjs": {
+      "version": "6.6.7",
+      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
+      "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
+      "dev": true,
+      "dependencies": {
+        "tslib": "^1.9.0"
+      },
+      "engines": {
+        "npm": ">=2.0.0"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ]
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true
+    },
+    "node_modules/sass": {
+      "version": "1.49.7",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.7.tgz",
+      "integrity": "sha512-13dml55EMIR2rS4d/RDHHP0sXMY3+30e1TKsyXaSz3iLWVoDWEoboY8WzJd5JMnxrRHffKO3wq2mpJ0jxRJiEQ==",
+      "dependencies": {
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      }
+    },
+    "node_modules/sass-loader": {
+      "version": "12.6.0",
+      "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
+      "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==",
+      "dev": true,
+      "dependencies": {
+        "klona": "^2.0.4",
+        "neo-async": "^2.6.2"
+      },
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "fibers": ">= 3.1.0",
+        "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
+        "sass": "^1.3.0",
+        "sass-embedded": "*",
+        "webpack": "^5.0.0"
+      },
+      "peerDependenciesMeta": {
+        "fibers": {
+          "optional": true
+        },
+        "node-sass": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/saxes": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
+      "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
+      "dev": true,
+      "dependencies": {
+        "xmlchars": "^2.2.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/scheduler": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
+      "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+      "dependencies": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      }
+    },
+    "node_modules/schema-utils": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+      "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/json-schema": "^7.0.8",
+        "ajv": "^6.12.5",
+        "ajv-keywords": "^3.5.2"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      }
+    },
+    "node_modules/screenfull": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
+      "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==",
+      "engines": {
+        "node": ">=0.10.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "dev": true,
+      "dependencies": {
+        "lru-cache": "^6.0.0"
+      },
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "node_modules/set-harmonic-interval": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz",
+      "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==",
+      "engines": {
+        "node": ">=6.9"
+      }
+    },
+    "node_modules/shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "dependencies": {
+        "shebang-regex": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true
+    },
+    "node_modules/sisteransi": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+      "dev": true
+    },
+    "node_modules/slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/slice-ansi": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
+      "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/source-map-resolve": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz",
+      "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==",
+      "deprecated": "See https://github.com/lydell/source-map-resolve#deprecated",
+      "dev": true,
+      "dependencies": {
+        "atob": "^2.1.2",
+        "decode-uri-component": "^0.2.0"
+      }
+    },
+    "node_modules/source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dev": true,
+      "dependencies": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "node_modules/sourcemap-codec": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+      "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
+    },
+    "node_modules/spdx-correct": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "node_modules/spdx-license-ids": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz",
+      "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/specificity": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz",
+      "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==",
+      "dev": true,
+      "peer": true,
+      "bin": {
+        "specificity": "bin/specificity"
+      }
+    },
+    "node_modules/sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
+    },
+    "node_modules/sshpk": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
+      "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
+      "dev": true,
+      "dependencies": {
+        "asn1": "~0.2.3",
+        "assert-plus": "^1.0.0",
+        "bcrypt-pbkdf": "^1.0.0",
+        "dashdash": "^1.12.0",
+        "ecc-jsbn": "~0.1.1",
+        "getpass": "^0.1.1",
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.0.2",
+        "tweetnacl": "~0.14.0"
+      },
+      "bin": {
+        "sshpk-conv": "bin/sshpk-conv",
+        "sshpk-sign": "bin/sshpk-sign",
+        "sshpk-verify": "bin/sshpk-verify"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stack-generator": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz",
+      "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==",
+      "dependencies": {
+        "stackframe": "^1.1.1"
+      }
+    },
+    "node_modules/stack-utils": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz",
+      "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==",
+      "dev": true,
+      "dependencies": {
+        "escape-string-regexp": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/stack-utils/node_modules/escape-string-regexp": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+      "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/stackframe": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.1.tgz",
+      "integrity": "sha512-h88QkzREN/hy8eRdyNhhsO7RSJ5oyTqxxmmn0dzBIMUclZsjpfmrsg81vp8mjjAs2vAZ72nyWxRUwSwmh0e4xg=="
+    },
+    "node_modules/stacktrace-gps": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz",
+      "integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==",
+      "dependencies": {
+        "source-map": "0.5.6",
+        "stackframe": "^1.1.1"
+      }
+    },
+    "node_modules/stacktrace-gps/node_modules/source-map": {
+      "version": "0.5.6",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
+      "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI=",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/stacktrace-js": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
+      "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
+      "dependencies": {
+        "error-stack-parser": "^2.0.6",
+        "stack-generator": "^2.0.5",
+        "stacktrace-gps": "^3.0.4"
+      }
+    },
+    "node_modules/string-length": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
+      "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
+      "dev": true,
+      "dependencies": {
+        "char-regex": "^1.0.2",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "dependencies": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/string.prototype.matchall": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+      "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1",
+        "get-intrinsic": "^1.1.1",
+        "has-symbols": "^1.0.3",
+        "internal-slot": "^1.0.3",
+        "regexp.prototype.flags": "^1.4.1",
+        "side-channel": "^1.0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimend": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+      "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/string.prototype.trimstart": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+      "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+      "dev": true,
+      "dependencies": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "dependencies": {
+        "ansi-regex": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-bom": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+      "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "dev": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/strip-indent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+      "dev": true,
+      "dependencies": {
+        "min-indent": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/style-loader": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.2.1.tgz",
+      "integrity": "sha512-1k9ZosJCRFaRbY6hH49JFlRB0fVSbmnyq1iTPjNxUmGVjBNEmwrrHPenhlp+Lgo51BojHSf6pl2FcqYaN3PfVg==",
+      "dev": true,
+      "engines": {
+        "node": ">= 12.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.0.0"
+      }
+    },
+    "node_modules/style-search": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
+      "integrity": "sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI=",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/stylelint": {
+      "version": "14.8.2",
+      "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-14.8.2.tgz",
+      "integrity": "sha512-tjDfexCYfoPdl/xcDJ9Fv+Ko9cvzbDnmdiaqEn3ovXHXasi/hbkt5tSjsiReQ+ENqnz0eltaX/AOO+AlzVdcNA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "balanced-match": "^2.0.0",
+        "colord": "^2.9.2",
+        "cosmiconfig": "^7.0.1",
+        "css-functions-list": "^3.0.1",
+        "debug": "^4.3.4",
+        "execall": "^2.0.0",
+        "fast-glob": "^3.2.11",
+        "fastest-levenshtein": "^1.0.12",
+        "file-entry-cache": "^6.0.1",
+        "get-stdin": "^8.0.0",
+        "global-modules": "^2.0.0",
+        "globby": "^11.1.0",
+        "globjoin": "^0.1.4",
+        "html-tags": "^3.2.0",
+        "ignore": "^5.2.0",
+        "import-lazy": "^4.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-plain-object": "^5.0.0",
+        "known-css-properties": "^0.25.0",
+        "mathml-tag-names": "^2.1.3",
+        "meow": "^9.0.0",
+        "micromatch": "^4.0.5",
+        "normalize-path": "^3.0.0",
+        "normalize-selector": "^0.2.0",
+        "picocolors": "^1.0.0",
+        "postcss": "^8.4.13",
+        "postcss-media-query-parser": "^0.2.3",
+        "postcss-resolve-nested-selector": "^0.1.1",
+        "postcss-safe-parser": "^6.0.0",
+        "postcss-selector-parser": "^6.0.10",
+        "postcss-value-parser": "^4.2.0",
+        "resolve-from": "^5.0.0",
+        "specificity": "^0.4.1",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1",
+        "style-search": "^0.1.0",
+        "supports-hyperlinks": "^2.2.0",
+        "svg-tags": "^1.0.0",
+        "table": "^6.8.0",
+        "v8-compile-cache": "^2.3.0",
+        "write-file-atomic": "^4.0.1"
+      },
+      "bin": {
+        "stylelint": "bin/stylelint.js"
+      },
+      "engines": {
+        "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/stylelint"
+      }
+    },
+    "node_modules/stylelint-config-css-modules": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-css-modules/-/stylelint-config-css-modules-4.1.0.tgz",
+      "integrity": "sha512-w6d552NscwvpUEaUcmq8GgWXKRv6lVHLbDj6QIHSM2vCWr83qRqRvXBJCfXDyaG/J3Zojw2inU9VvU99ZlXuUw==",
+      "dev": true,
+      "optionalDependencies": {
+        "stylelint-scss": "^4.2.0"
+      },
+      "peerDependencies": {
+        "stylelint": "^14.5.1"
+      }
+    },
+    "node_modules/stylelint-config-recess-order": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-recess-order/-/stylelint-config-recess-order-3.0.0.tgz",
+      "integrity": "sha512-uNXrlDz570Q7HJlrq8mNjgfO/xlKIh2hKVKEFMTG1/ih/6tDLcTbuvO1Zoo2dnQay990OAkWLDpTDOorB+hmBw==",
+      "dev": true,
+      "dependencies": {
+        "stylelint-order": "5.x"
+      },
+      "peerDependencies": {
+        "stylelint": ">=14"
+      }
+    },
+    "node_modules/stylelint-config-recommended": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-7.0.0.tgz",
+      "integrity": "sha512-yGn84Bf/q41J4luis1AZ95gj0EQwRX8lWmGmBwkwBNSkpGSpl66XcPTulxGa/Z91aPoNGuIGBmFkcM1MejMo9Q==",
+      "dev": true,
+      "peerDependencies": {
+        "stylelint": "^14.4.0"
+      }
+    },
+    "node_modules/stylelint-config-standard": {
+      "version": "25.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-25.0.0.tgz",
+      "integrity": "sha512-21HnP3VSpaT1wFjFvv9VjvOGDtAviv47uTp3uFmzcN+3Lt+RYRv6oAplLaV51Kf792JSxJ6svCJh/G18E9VnCA==",
+      "dev": true,
+      "dependencies": {
+        "stylelint-config-recommended": "^7.0.0"
+      },
+      "peerDependencies": {
+        "stylelint": "^14.4.0"
+      }
+    },
+    "node_modules/stylelint-order": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-5.0.0.tgz",
+      "integrity": "sha512-OWQ7pmicXufDw5BlRqzdz3fkGKJPgLyDwD1rFY3AIEfIH/LQY38Vu/85v8/up0I+VPiuGRwbc2Hg3zLAsJaiyw==",
+      "dev": true,
+      "dependencies": {
+        "postcss": "^8.3.11",
+        "postcss-sorting": "^7.0.1"
+      },
+      "peerDependencies": {
+        "stylelint": "^14.0.0"
+      }
+    },
+    "node_modules/stylelint-scss": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-4.2.0.tgz",
+      "integrity": "sha512-HHHMVKJJ5RM9pPIbgJ/XA67h9H0407G68Rm69H4fzFbFkyDMcTV1Byep3qdze5+fJ3c0U7mJrbj6S0Fg072uZA==",
+      "dev": true,
+      "dependencies": {
+        "lodash": "^4.17.21",
+        "postcss-media-query-parser": "^0.2.3",
+        "postcss-resolve-nested-selector": "^0.1.1",
+        "postcss-selector-parser": "^6.0.6",
+        "postcss-value-parser": "^4.1.0"
+      },
+      "peerDependencies": {
+        "stylelint": "^14.5.1"
+      }
+    },
+    "node_modules/stylelint/node_modules/balanced-match": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
+      "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/stylelint/node_modules/cosmiconfig": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+      "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/parse-json": "^4.0.0",
+        "import-fresh": "^3.2.1",
+        "parse-json": "^5.0.0",
+        "path-type": "^4.0.0",
+        "yaml": "^1.10.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/stylelint/node_modules/resolve-from": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+      "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/stylelint/node_modules/write-file-atomic": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.1.tgz",
+      "integrity": "sha512-nSKUxgAbyioruk6hU87QzVbY279oYT6uiwgDoujth2ju4mJ+TZau7SQBhtbTmUyuNYTuXnSyRn66FV0+eCgcrQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "imurmurhash": "^0.1.4",
+        "signal-exit": "^3.0.7"
+      },
+      "engines": {
+        "node": "^12.13.0 || ^14.15.0 || >=16"
+      }
+    },
+    "node_modules/stylis": {
+      "version": "4.0.13",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz",
+      "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag=="
+    },
+    "node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
+    "node_modules/supports-hyperlinks": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz",
+      "integrity": "sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0",
+        "supports-color": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-hyperlinks/node_modules/supports-color": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+      "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+      "dev": true,
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/svg-tags": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
+      "integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true
+    },
+    "node_modules/table": {
+      "version": "6.8.0",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
+      "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "ajv": "^8.0.1",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1"
+      },
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/table/node_modules/ajv": {
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+      "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "fast-deep-equal": "^3.1.1",
+        "json-schema-traverse": "^1.0.0",
+        "require-from-string": "^2.0.2",
+        "uri-js": "^4.2.2"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/epoberezkin"
+      }
+    },
+    "node_modules/table/node_modules/json-schema-traverse": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+      "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+      "dev": true,
+      "peer": true
+    },
+    "node_modules/table/node_modules/slice-ansi": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+      "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/slice-ansi?sponsor=1"
+      }
+    },
+    "node_modules/tapable": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz",
+      "integrity": "sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/terminal-link": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
+      "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==",
+      "dev": true,
+      "dependencies": {
+        "ansi-escapes": "^4.2.1",
+        "supports-hyperlinks": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/terser": {
+      "version": "5.14.2",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
+      "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@jridgewell/source-map": "^0.3.2",
+        "acorn": "^8.5.0",
+        "commander": "^2.20.0",
+        "source-map-support": "~0.5.20"
+      },
+      "bin": {
+        "terser": "bin/terser"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/terser-webpack-plugin": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.2.3.tgz",
+      "integrity": "sha512-eDbuaDlXhVaaoKuLD3DTNTozKqln6xOG6Us0SzlKG5tNlazG+/cdl8pm9qiF1Di89iWScTI0HcO+CDcf2dkXiw==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "jest-worker": "^27.0.6",
+        "p-limit": "^3.1.0",
+        "schema-utils": "^3.1.1",
+        "serialize-javascript": "^6.0.0",
+        "source-map": "^0.6.1",
+        "terser": "^5.7.2"
+      },
+      "engines": {
+        "node": ">= 10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependencies": {
+        "webpack": "^5.1.0"
+      },
+      "peerDependenciesMeta": {
+        "@swc/core": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        },
+        "uglify-js": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/test-exclude": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+      "dev": true,
+      "dependencies": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^7.1.4",
+        "minimatch": "^3.0.4"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+      "dev": true
+    },
+    "node_modules/throat": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz",
+      "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==",
+      "dev": true
+    },
+    "node_modules/throttle-debounce": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz",
+      "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/throttleit": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
+      "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=",
+      "dev": true
+    },
+    "node_modules/through": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+      "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+      "dev": true
+    },
+    "node_modules/tmp": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
+      "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+      "dev": true,
+      "dependencies": {
+        "rimraf": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=8.17.0"
+      }
+    },
+    "node_modules/tmpl": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+      "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+      "dev": true
+    },
+    "node_modules/to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/toggle-selection": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
+      "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI="
+    },
+    "node_modules/tough-cookie": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+      "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+      "dev": true,
+      "dependencies": {
+        "psl": "^1.1.28",
+        "punycode": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz",
+      "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/trim-newlines": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
+      "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/ts-easing": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
+      "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ=="
+    },
+    "node_modules/ts-jest": {
+      "version": "27.1.3",
+      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.3.tgz",
+      "integrity": "sha512-6Nlura7s6uM9BVUAoqLH7JHyMXjz8gluryjpPXxr3IxZdAXnU6FhjvVLHFtfd1vsE1p8zD1OJfskkc0jhTSnkA==",
+      "dev": true,
+      "dependencies": {
+        "bs-logger": "0.x",
+        "fast-json-stable-stringify": "2.x",
+        "jest-util": "^27.0.0",
+        "json5": "2.x",
+        "lodash.memoize": "4.x",
+        "make-error": "1.x",
+        "semver": "7.x",
+        "yargs-parser": "20.x"
+      },
+      "bin": {
+        "ts-jest": "cli.js"
+      },
+      "engines": {
+        "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+      },
+      "peerDependencies": {
+        "@babel/core": ">=7.0.0-beta.0 <8",
+        "@types/jest": "^27.0.0",
+        "babel-jest": ">=27.0.0 <28",
+        "esbuild": "~0.14.0",
+        "jest": "^27.0.0",
+        "typescript": ">=3.8 <5.0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "@types/jest": {
+          "optional": true
+        },
+        "babel-jest": {
+          "optional": true
+        },
+        "esbuild": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ts-node": {
+      "version": "10.7.0",
+      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz",
+      "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "dependencies": {
+        "@cspotcode/source-map-support": "0.7.0",
+        "@tsconfig/node10": "^1.0.7",
+        "@tsconfig/node12": "^1.0.7",
+        "@tsconfig/node14": "^1.0.0",
+        "@tsconfig/node16": "^1.0.2",
+        "acorn": "^8.4.1",
+        "acorn-walk": "^8.1.1",
+        "arg": "^4.1.0",
+        "create-require": "^1.1.0",
+        "diff": "^4.0.1",
+        "make-error": "^1.1.1",
+        "v8-compile-cache-lib": "^3.0.0",
+        "yn": "3.1.1"
+      },
+      "bin": {
+        "ts-node": "dist/bin.js",
+        "ts-node-cwd": "dist/bin-cwd.js",
+        "ts-node-esm": "dist/bin-esm.js",
+        "ts-node-script": "dist/bin-script.js",
+        "ts-node-transpile-only": "dist/bin-transpile.js",
+        "ts-script": "dist/bin-script-deprecated.js"
+      },
+      "peerDependencies": {
+        "@swc/core": ">=1.2.50",
+        "@swc/wasm": ">=1.2.50",
+        "@types/node": "*",
+        "typescript": ">=2.7"
+      },
+      "peerDependenciesMeta": {
+        "@swc/core": {
+          "optional": true
+        },
+        "@swc/wasm": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/ts-node/node_modules/acorn-walk": {
+      "version": "8.2.0",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
+      "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/tsconfig-paths": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",
+      "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==",
+      "dev": true,
+      "dependencies": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.6",
+        "strip-bom": "^3.0.0"
+      }
+    },
+    "node_modules/tsconfig-paths/node_modules/json5": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+      "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+      "dev": true,
+      "dependencies": {
+        "minimist": "^1.2.0"
+      },
+      "bin": {
+        "json5": "lib/cli.js"
+      }
+    },
+    "node_modules/tsconfig-paths/node_modules/strip-bom": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+      "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+    },
+    "node_modules/tsutils": {
+      "version": "3.21.0",
+      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
+      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
+      "dev": true,
+      "dependencies": {
+        "tslib": "^1.8.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      },
+      "peerDependencies": {
+        "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta"
+      }
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+      "dev": true,
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
+      "dev": true
+    },
+    "node_modules/type-check": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+      "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+      "dev": true,
+      "dependencies": {
+        "prelude-ls": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true,
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/type-fest": {
+      "version": "0.21.3",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+      "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/typedarray-to-buffer": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+      "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+      "dev": true,
+      "dependencies": {
+        "is-typedarray": "^1.0.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.4.tgz",
+      "integrity": "sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg==",
+      "dev": true,
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=4.2.0"
+      }
+    },
+    "node_modules/unbox-primitive": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+      "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+      "dev": true,
+      "dependencies": {
+        "function-bind": "^1.1.1",
+        "has-bigints": "^1.0.1",
+        "has-symbols": "^1.0.2",
+        "which-boxed-primitive": "^1.0.2"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/universalify": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+      "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 10.0.0"
+      }
+    },
+    "node_modules/unload": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
+      "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
+      "dependencies": {
+        "@babel/runtime": "^7.6.2",
+        "detect-node": "^2.0.4"
+      }
+    },
+    "node_modules/untildify": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
+      "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
+      "dev": true,
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "dependencies": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "node_modules/url": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+      "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=",
+      "dev": true,
+      "dependencies": {
+        "punycode": "1.3.2",
+        "querystring": "0.2.0"
+      }
+    },
+    "node_modules/url/node_modules/punycode": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+      "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=",
+      "dev": true
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
+    },
+    "node_modules/uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "dev": true,
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/v8-compile-cache": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+      "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+      "dev": true
+    },
+    "node_modules/v8-compile-cache-lib": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz",
+      "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "node_modules/v8-to-istanbul": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz",
+      "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==",
+      "dev": true,
+      "dependencies": {
+        "@types/istanbul-lib-coverage": "^2.0.1",
+        "convert-source-map": "^1.6.0",
+        "source-map": "^0.7.3"
+      },
+      "engines": {
+        "node": ">=10.12.0"
+      }
+    },
+    "node_modules/v8-to-istanbul/node_modules/source-map": {
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
+      "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "node_modules/verror": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+      "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+      "dev": true,
+      "engines": [
+        "node >=0.6.0"
+      ],
+      "dependencies": {
+        "assert-plus": "^1.0.0",
+        "core-util-is": "1.0.2",
+        "extsprintf": "^1.2.0"
+      }
+    },
+    "node_modules/w3c-hr-time": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+      "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==",
+      "dev": true,
+      "dependencies": {
+        "browser-process-hrtime": "^1.0.0"
+      }
+    },
+    "node_modules/w3c-xmlserializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz",
+      "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==",
+      "dev": true,
+      "dependencies": {
+        "xml-name-validator": "^3.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/walker": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+      "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+      "dev": true,
+      "dependencies": {
+        "makeerror": "1.0.12"
+      }
+    },
+    "node_modules/watchpack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.2.0.tgz",
+      "integrity": "sha512-up4YAn/XHgZHIxFBVCdlMiWDj6WaLKpwVeGQk2I5thdYxF/KmF0aaz6TfJZ/hfl1h/XlcDr7k1KH7ThDagpFaA==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "glob-to-regexp": "^0.4.1",
+        "graceful-fs": "^4.1.2"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/web-streams-polyfill": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz",
+      "integrity": "sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==",
+      "dev": true,
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
+      "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==",
+      "dev": true,
+      "engines": {
+        "node": ">=10.4"
+      }
+    },
+    "node_modules/webpack": {
+      "version": "5.52.0",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.52.0.tgz",
+      "integrity": "sha512-yRZOat8jWGwBwHpco3uKQhVU7HYaNunZiJ4AkAVQkPCUGoZk/tiIXiwG+8HIy/F+qsiZvSOa+GLQOj3q5RKRYg==",
+      "dev": true,
+      "peer": true,
+      "dependencies": {
+        "@types/eslint-scope": "^3.7.0",
+        "@types/estree": "^0.0.50",
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/wasm-edit": "1.11.1",
+        "@webassemblyjs/wasm-parser": "1.11.1",
+        "acorn": "^8.4.1",
+        "acorn-import-assertions": "^1.7.6",
+        "browserslist": "^4.14.5",
+        "chrome-trace-event": "^1.0.2",
+        "enhanced-resolve": "^5.8.0",
+        "es-module-lexer": "^0.7.1",
+        "eslint-scope": "5.1.1",
+        "events": "^3.2.0",
+        "glob-to-regexp": "^0.4.1",
+        "graceful-fs": "^4.2.4",
+        "json-parse-better-errors": "^1.0.2",
+        "loader-runner": "^4.2.0",
+        "mime-types": "^2.1.27",
+        "neo-async": "^2.6.2",
+        "schema-utils": "^3.1.0",
+        "tapable": "^2.1.1",
+        "terser-webpack-plugin": "^5.1.3",
+        "watchpack": "^2.2.0",
+        "webpack-sources": "^3.2.0"
+      },
+      "bin": {
+        "webpack": "bin/webpack.js"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/webpack"
+      },
+      "peerDependenciesMeta": {
+        "webpack-cli": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/webpack-sources": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.0.tgz",
+      "integrity": "sha512-fahN08Et7P9trej8xz/Z7eRu8ltyiygEo/hnRi9KqBUs80KeDcnf96ZJo++ewWd84fEf3xSX9bp4ZS9hbw0OBw==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
+      "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==",
+      "dev": true,
+      "dependencies": {
+        "iconv-lite": "0.4.24"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",
+      "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==",
+      "dev": true
+    },
+    "node_modules/whatwg-url": {
+      "version": "8.7.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz",
+      "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==",
+      "dev": true,
+      "dependencies": {
+        "lodash": "^4.7.0",
+        "tr46": "^2.1.0",
+        "webidl-conversions": "^6.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "dependencies": {
+        "isexe": "^2.0.0"
+      },
+      "bin": {
+        "node-which": "bin/node-which"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/which-boxed-primitive": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+      "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+      "dev": true,
+      "dependencies": {
+        "is-bigint": "^1.0.1",
+        "is-boolean-object": "^1.1.0",
+        "is-number-object": "^1.0.4",
+        "is-string": "^1.0.5",
+        "is-symbol": "^1.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/wicg-inert": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/wicg-inert/-/wicg-inert-3.1.1.tgz",
+      "integrity": "sha512-PhBaNh8ur9Xm4Ggy4umelwNIP6pPP1bv3EaWaKqfb/QNme2rdLjm7wIInvV4WhxVHhzA4Spgw9qNSqWtB/ca2A=="
+    },
+    "node_modules/word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "dependencies": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
+      }
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "node_modules/write-file-atomic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+      "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+      "dev": true,
+      "dependencies": {
+        "imurmurhash": "^0.1.4",
+        "is-typedarray": "^1.0.0",
+        "signal-exit": "^3.0.2",
+        "typedarray-to-buffer": "^3.1.5"
+      }
+    },
+    "node_modules/xml-name-validator": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
+      "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==",
+      "dev": true
+    },
+    "node_modules/xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true
+    },
+    "node_modules/y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "dev": true
+    },
+    "node_modules/yaml": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "dependencies": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true,
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/yauzl": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+      "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
+      "dev": true,
+      "dependencies": {
+        "buffer-crc32": "~0.2.3",
+        "fd-slicer": "~1.1.0"
+      }
+    },
+    "node_modules/yn": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+      "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "peer": true,
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    }
+  },
+  "dependencies": {
+    "@ampproject/remapping": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.1.2.tgz",
+      "integrity": "sha512-hoyByceqwKirw7w3Z7gnIIZC3Wx3J484Y3L/cMpXFbr7d9ZQj2mODrirNzcJa+SM3UlpWXYvKV4RlRpFXlWgXg==",
+      "requires": {
+        "@jridgewell/trace-mapping": "^0.3.0"
+      }
+    },
+    "@babel/code-frame": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.16.7.tgz",
+      "integrity": "sha512-iAXqUn8IIeBTNd72xsFlgaXHkMBMt6y4HJp1tIaK465CWLT/fG1aqB7ykr95gHHmlBdGbFeWWfyB4NJJ0nmeIg==",
+      "requires": {
+        "@babel/highlight": "^7.16.7"
+      }
+    },
+    "@babel/compat-data": {
+      "version": "7.17.0",
+      "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.17.0.tgz",
+      "integrity": "sha512-392byTlpGWXMv4FbyWw3sAZ/FrW/DrwqLGXpy0mbyNe9Taqv1mg9yON5/o0cnr8XYCkFTZbC1eV+c+LAROgrng=="
+    },
+    "@babel/core": {
+      "version": "7.17.5",
+      "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.17.5.tgz",
+      "integrity": "sha512-/BBMw4EvjmyquN5O+t5eh0+YqB3XXJkYD2cjKpYtWOfFy4lQ4UozNSmxAcWT8r2XtZs0ewG+zrfsqeR15i1ajA==",
+      "requires": {
+        "@ampproject/remapping": "^2.1.0",
+        "@babel/code-frame": "^7.16.7",
+        "@babel/generator": "^7.17.3",
+        "@babel/helper-compilation-targets": "^7.16.7",
+        "@babel/helper-module-transforms": "^7.16.7",
+        "@babel/helpers": "^7.17.2",
+        "@babel/parser": "^7.17.3",
+        "@babel/template": "^7.16.7",
+        "@babel/traverse": "^7.17.3",
+        "@babel/types": "^7.17.0",
+        "convert-source-map": "^1.7.0",
+        "debug": "^4.1.0",
+        "gensync": "^1.0.0-beta.2",
+        "json5": "^2.1.2",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        }
+      }
+    },
+    "@babel/generator": {
+      "version": "7.17.3",
+      "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.17.3.tgz",
+      "integrity": "sha512-+R6Dctil/MgUsZsZAkYgK+ADNSZzJRRy0TvY65T71z/CR854xHQ1EweBYXdfT+HNeN7w0cSJJEzgxZMv40pxsg==",
+      "requires": {
+        "@babel/types": "^7.17.0",
+        "jsesc": "^2.5.1",
+        "source-map": "^0.5.0"
+      },
+      "dependencies": {
+        "source-map": {
+          "version": "0.5.7",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+          "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+        }
+      }
+    },
+    "@babel/helper-compilation-targets": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.16.7.tgz",
+      "integrity": "sha512-mGojBwIWcwGD6rfqgRXVlVYmPAv7eOpIemUG3dGnDdCY4Pae70ROij3XmfrH6Fa1h1aiDylpglbZyktfzyo/hA==",
+      "requires": {
+        "@babel/compat-data": "^7.16.4",
+        "@babel/helper-validator-option": "^7.16.7",
+        "browserslist": "^4.17.5",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw=="
+        }
+      }
+    },
+    "@babel/helper-environment-visitor": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.16.7.tgz",
+      "integrity": "sha512-SLLb0AAn6PkUeAfKJCCOl9e1R53pQlGAfc4y4XuMRZfqeMYLE0dM1LMhqbGAlGQY0lfw5/ohoYWAe9V1yibRag==",
+      "requires": {
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/helper-function-name": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.16.7.tgz",
+      "integrity": "sha512-QfDfEnIUyyBSR3HtrtGECuZ6DAyCkYFp7GHl75vFtTnn6pjKeK0T1DB5lLkFvBea8MdaiUABx3osbgLyInoejA==",
+      "requires": {
+        "@babel/helper-get-function-arity": "^7.16.7",
+        "@babel/template": "^7.16.7",
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/helper-get-function-arity": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.16.7.tgz",
+      "integrity": "sha512-flc+RLSOBXzNzVhcLu6ujeHUrD6tANAOU5ojrRx/as+tbzf8+stUCj7+IfRRoAbEZqj/ahXEMsjhOhgeZsrnTw==",
+      "requires": {
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/helper-hoist-variables": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.16.7.tgz",
+      "integrity": "sha512-m04d/0Op34H5v7pbZw6pSKP7weA6lsMvfiIAMeIvkY/R4xQtBSMFEigu9QTZ2qB/9l22vsxtM8a+Q8CzD255fg==",
+      "requires": {
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/helper-module-imports": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.16.7.tgz",
+      "integrity": "sha512-LVtS6TqjJHFc+nYeITRo6VLXve70xmq7wPhWTqDJusJEgGmkAACWwMiTNrvfoQo6hEhFwAIixNkvB0jPXDL8Wg==",
+      "requires": {
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/helper-module-transforms": {
+      "version": "7.17.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.17.7.tgz",
+      "integrity": "sha512-VmZD99F3gNTYB7fJRDTi+u6l/zxY0BE6OIxPSU7a50s6ZUQkHwSDmV92FfM+oCG0pZRVojGYhkR8I0OGeCVREw==",
+      "requires": {
+        "@babel/helper-environment-visitor": "^7.16.7",
+        "@babel/helper-module-imports": "^7.16.7",
+        "@babel/helper-simple-access": "^7.17.7",
+        "@babel/helper-split-export-declaration": "^7.16.7",
+        "@babel/helper-validator-identifier": "^7.16.7",
+        "@babel/template": "^7.16.7",
+        "@babel/traverse": "^7.17.3",
+        "@babel/types": "^7.17.0"
+      }
+    },
+    "@babel/helper-plugin-utils": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.16.7.tgz",
+      "integrity": "sha512-Qg3Nk7ZxpgMrsox6HreY1ZNKdBq7K72tDSliA6dCl5f007jR4ne8iD5UzuNnCJH2xBf2BEEVGr+/OL6Gdp7RxA=="
+    },
+    "@babel/helper-simple-access": {
+      "version": "7.17.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.17.7.tgz",
+      "integrity": "sha512-txyMCGroZ96i+Pxr3Je3lzEJjqwaRC9buMUgtomcrLe5Nd0+fk1h0LLA+ixUF5OW7AhHuQ7Es1WcQJZmZsz2XA==",
+      "requires": {
+        "@babel/types": "^7.17.0"
+      }
+    },
+    "@babel/helper-split-export-declaration": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.16.7.tgz",
+      "integrity": "sha512-xbWoy/PFoxSWazIToT9Sif+jJTlrMcndIsaOKvTA6u7QEo7ilkRZpjew18/W3c7nm8fXdUDXh02VXTbZ0pGDNw==",
+      "requires": {
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/helper-validator-identifier": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.16.7.tgz",
+      "integrity": "sha512-hsEnFemeiW4D08A5gUAZxLBTXpZ39P+a+DGDsHw1yxqyQ/jzFEnxf5uTEGp+3bzAbNOxU1paTgYS4ECU/IgfDw=="
+    },
+    "@babel/helper-validator-option": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.16.7.tgz",
+      "integrity": "sha512-TRtenOuRUVo9oIQGPC5G9DgK4743cdxvtOw0weQNpZXaS16SCBi5MNjZF8vba3ETURjZpTbVn7Vvcf2eAwFozQ=="
+    },
+    "@babel/helpers": {
+      "version": "7.17.2",
+      "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.17.2.tgz",
+      "integrity": "sha512-0Qu7RLR1dILozr/6M0xgj+DFPmi6Bnulgm9M8BVa9ZCWxDqlSnqt3cf8IDPB5m45sVXUZ0kuQAgUrdSFFH79fQ==",
+      "requires": {
+        "@babel/template": "^7.16.7",
+        "@babel/traverse": "^7.17.0",
+        "@babel/types": "^7.17.0"
+      }
+    },
+    "@babel/highlight": {
+      "version": "7.16.10",
+      "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.16.10.tgz",
+      "integrity": "sha512-5FnTQLSLswEj6IkgVw5KusNUUFY9ZGqe/TRFnP/BKYHYgfh7tc+C7mwiy95/yNP7Dh9x580Vv8r7u7ZfTBFxdw==",
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.16.7",
+        "chalk": "^2.0.0",
+        "js-tokens": "^4.0.0"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "3.2.1",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
+          "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
+          "requires": {
+            "color-convert": "^1.9.0"
+          }
+        },
+        "chalk": {
+          "version": "2.4.2",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
+          "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
+          "requires": {
+            "ansi-styles": "^3.2.1",
+            "escape-string-regexp": "^1.0.5",
+            "supports-color": "^5.3.0"
+          }
+        },
+        "color-convert": {
+          "version": "1.9.3",
+          "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",
+          "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",
+          "requires": {
+            "color-name": "1.1.3"
+          }
+        },
+        "color-name": {
+          "version": "1.1.3",
+          "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
+          "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
+        },
+        "has-flag": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
+          "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="
+        },
+        "supports-color": {
+          "version": "5.5.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
+          "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
+          "requires": {
+            "has-flag": "^3.0.0"
+          }
+        }
+      }
+    },
+    "@babel/parser": {
+      "version": "7.17.3",
+      "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.17.3.tgz",
+      "integrity": "sha512-7yJPvPV+ESz2IUTPbOL+YkIGyCqOyNIzdguKQuJGnH7bg1WTIifuM21YqokFt/THWh1AkCRn9IgoykTRCBVpzA=="
+    },
+    "@babel/plugin-syntax-async-generators": {
+      "version": "7.8.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz",
+      "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-bigint": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz",
+      "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-class-properties": {
+      "version": "7.12.13",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz",
+      "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.12.13"
+      }
+    },
+    "@babel/plugin-syntax-import-meta": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz",
+      "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      }
+    },
+    "@babel/plugin-syntax-json-strings": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz",
+      "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-jsx": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.16.7.tgz",
+      "integrity": "sha512-Esxmk7YjA8QysKeT3VhTXvF6y77f/a91SIs4pWb4H2eWGQkCKFgQaG6hdoEVZtGsrAcb2K5BW66XsOErD4WU3Q==",
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.16.7"
+      }
+    },
+    "@babel/plugin-syntax-logical-assignment-operators": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz",
+      "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      }
+    },
+    "@babel/plugin-syntax-nullish-coalescing-operator": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz",
+      "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-numeric-separator": {
+      "version": "7.10.4",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz",
+      "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.10.4"
+      }
+    },
+    "@babel/plugin-syntax-object-rest-spread": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz",
+      "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-optional-catch-binding": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz",
+      "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-optional-chaining": {
+      "version": "7.8.3",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz",
+      "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.8.0"
+      }
+    },
+    "@babel/plugin-syntax-top-level-await": {
+      "version": "7.14.5",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz",
+      "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.14.5"
+      }
+    },
+    "@babel/plugin-syntax-typescript": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.16.7.tgz",
+      "integrity": "sha512-YhUIJHHGkqPgEcMYkPCKTyGUdoGKWtopIycQyjJH8OjvRgOYsXsaKehLVPScKJWAULPxMa4N1vCe6szREFlZ7A==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.16.7"
+      }
+    },
+    "@babel/runtime": {
+      "version": "7.17.2",
+      "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.2.tgz",
+      "integrity": "sha512-hzeyJyMA1YGdJTuWU0e/j4wKXrU4OMFvY2MSlaI9B7VQb0r5cxTE3EAIS2Q7Tn2RIcDkRvTA/v2JsAEhxe99uw==",
+      "requires": {
+        "regenerator-runtime": "^0.13.4"
+      }
+    },
+    "@babel/runtime-corejs3": {
+      "version": "7.17.9",
+      "resolved": "https://registry.npmjs.org/@babel/runtime-corejs3/-/runtime-corejs3-7.17.9.tgz",
+      "integrity": "sha512-WxYHHUWF2uZ7Hp1K+D1xQgbgkGUfA+5UPOegEXGt2Y5SMog/rYCVaifLZDbw8UkNXozEqqrZTy6bglL7xTaCOw==",
+      "dev": true,
+      "requires": {
+        "core-js-pure": "^3.20.2",
+        "regenerator-runtime": "^0.13.4"
+      }
+    },
+    "@babel/template": {
+      "version": "7.16.7",
+      "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.16.7.tgz",
+      "integrity": "sha512-I8j/x8kHUrbYRTUxXrrMbfCa7jxkE7tZre39x3kjr9hvI82cK1FfqLygotcWN5kdPGWcLdWMHpSBavse5tWw3w==",
+      "requires": {
+        "@babel/code-frame": "^7.16.7",
+        "@babel/parser": "^7.16.7",
+        "@babel/types": "^7.16.7"
+      }
+    },
+    "@babel/traverse": {
+      "version": "7.17.3",
+      "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.17.3.tgz",
+      "integrity": "sha512-5irClVky7TxRWIRtxlh2WPUUOLhcPN06AGgaQSB8AEwuyEBgJVuJ5imdHm5zxk8w0QS5T+tDfnDxAlhWjpb7cw==",
+      "requires": {
+        "@babel/code-frame": "^7.16.7",
+        "@babel/generator": "^7.17.3",
+        "@babel/helper-environment-visitor": "^7.16.7",
+        "@babel/helper-function-name": "^7.16.7",
+        "@babel/helper-hoist-variables": "^7.16.7",
+        "@babel/helper-split-export-declaration": "^7.16.7",
+        "@babel/parser": "^7.17.3",
+        "@babel/types": "^7.17.0",
+        "debug": "^4.1.0",
+        "globals": "^11.1.0"
+      }
+    },
+    "@babel/types": {
+      "version": "7.17.0",
+      "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.17.0.tgz",
+      "integrity": "sha512-TmKSNO4D5rzhL5bjWFcVHHLETzfQ/AmbKpKPOSjlP0WoHZ6L911fgoOKY4Alp/emzG4cHJdyN49zpgkbXFEHHw==",
+      "requires": {
+        "@babel/helper-validator-identifier": "^7.16.7",
+        "to-fast-properties": "^2.0.0"
+      }
+    },
+    "@bcoe/v8-coverage": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
+      "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==",
+      "dev": true
+    },
+    "@chopsui/prpc-client": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@chopsui/prpc-client/-/prpc-client-1.1.0.tgz",
+      "integrity": "sha512-7ej/IHxKMBXqi62HsnVZeR+OmAotmt48kn48x5ZlDlX1/4Jbzbye9fJ7mJ3pNMRnfA1DKsdsk/Dfedx4eoiEpQ=="
+    },
+    "@cspotcode/source-map-consumer": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz",
+      "integrity": "sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "@cspotcode/source-map-support": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz",
+      "integrity": "sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "requires": {
+        "@cspotcode/source-map-consumer": "0.8.0"
+      }
+    },
+    "@cypress/request": {
+      "version": "2.88.6",
+      "resolved": "https://registry.npmjs.org/@cypress/request/-/request-2.88.6.tgz",
+      "integrity": "sha512-z0UxBE/+qaESAHY9p9sM2h8Y4XqtsbDCt0/DPOrqA/RZgKi4PkxdpXyK4wCCnSk1xHqWHZZAE+gV6aDAR6+caQ==",
+      "dev": true,
+      "requires": {
+        "aws-sign2": "~0.7.0",
+        "aws4": "^1.8.0",
+        "caseless": "~0.12.0",
+        "combined-stream": "~1.0.6",
+        "extend": "~3.0.2",
+        "forever-agent": "~0.6.1",
+        "form-data": "~2.3.2",
+        "har-validator": "~5.1.3",
+        "http-signature": "~1.2.0",
+        "is-typedarray": "~1.0.0",
+        "isstream": "~0.1.2",
+        "json-stringify-safe": "~5.0.1",
+        "mime-types": "~2.1.19",
+        "performance-now": "^2.1.0",
+        "qs": "~6.5.2",
+        "safe-buffer": "^5.1.2",
+        "tough-cookie": "~2.5.0",
+        "tunnel-agent": "^0.6.0",
+        "uuid": "^8.3.2"
+      }
+    },
+    "@cypress/xvfb": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@cypress/xvfb/-/xvfb-1.2.4.tgz",
+      "integrity": "sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==",
+      "dev": true,
+      "requires": {
+        "debug": "^3.1.0",
+        "lodash.once": "^4.1.1"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.2.7",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+          "dev": true,
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "@emotion/babel-plugin": {
+      "version": "11.7.2",
+      "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.7.2.tgz",
+      "integrity": "sha512-6mGSCWi9UzXut/ZAN6lGFu33wGR3SJisNl3c0tvlmb8XChH1b2SUvxvnOh7hvLpqyRdHHU9AiazV3Cwbk5SXKQ==",
+      "requires": {
+        "@babel/helper-module-imports": "^7.12.13",
+        "@babel/plugin-syntax-jsx": "^7.12.13",
+        "@babel/runtime": "^7.13.10",
+        "@emotion/hash": "^0.8.0",
+        "@emotion/memoize": "^0.7.5",
+        "@emotion/serialize": "^1.0.2",
+        "babel-plugin-macros": "^2.6.1",
+        "convert-source-map": "^1.5.0",
+        "escape-string-regexp": "^4.0.0",
+        "find-root": "^1.1.0",
+        "source-map": "^0.5.7",
+        "stylis": "4.0.13"
+      },
+      "dependencies": {
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="
+        },
+        "source-map": {
+          "version": "0.5.7",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
+          "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="
+        }
+      }
+    },
+    "@emotion/cache": {
+      "version": "11.9.3",
+      "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.9.3.tgz",
+      "integrity": "sha512-0dgkI/JKlCXa+lEXviaMtGBL0ynpx4osh7rjOXE71q9bIF8G+XhJgvi+wDu0B0IdCVx37BffiwXlN9I3UuzFvg==",
+      "requires": {
+        "@emotion/memoize": "^0.7.4",
+        "@emotion/sheet": "^1.1.1",
+        "@emotion/utils": "^1.0.0",
+        "@emotion/weak-memoize": "^0.2.5",
+        "stylis": "4.0.13"
+      }
+    },
+    "@emotion/hash": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz",
+      "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow=="
+    },
+    "@emotion/is-prop-valid": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.1.3.tgz",
+      "integrity": "sha512-RFg04p6C+1uO19uG8N+vqanzKqiM9eeV1LDOG3bmkYmuOj7NbKNlFC/4EZq5gnwAIlcC/jOT24f8Td0iax2SXA==",
+      "requires": {
+        "@emotion/memoize": "^0.7.4"
+      }
+    },
+    "@emotion/memoize": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.5.tgz",
+      "integrity": "sha512-igX9a37DR2ZPGYtV6suZ6whr8pTFtyHL3K/oLUotxpSVO2ASaprmAe2Dkq7tBo7CRY7MMDrAa9nuQP9/YG8FxQ=="
+    },
+    "@emotion/react": {
+      "version": "11.8.2",
+      "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.8.2.tgz",
+      "integrity": "sha512-+1bcHBaNJv5nkIIgnGKVsie3otS0wF9f1T1hteF3WeVvMNQEtfZ4YyFpnphGoot3ilU/wWMgP2SgIDuHLE/wAA==",
+      "requires": {
+        "@babel/runtime": "^7.13.10",
+        "@emotion/babel-plugin": "^11.7.1",
+        "@emotion/cache": "^11.7.1",
+        "@emotion/serialize": "^1.0.2",
+        "@emotion/utils": "^1.1.0",
+        "@emotion/weak-memoize": "^0.2.5",
+        "hoist-non-react-statics": "^3.3.1"
+      }
+    },
+    "@emotion/serialize": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.0.2.tgz",
+      "integrity": "sha512-95MgNJ9+/ajxU7QIAruiOAdYNjxZX7G2mhgrtDWswA21VviYIRP1R5QilZ/bDY42xiKsaktP4egJb3QdYQZi1A==",
+      "requires": {
+        "@emotion/hash": "^0.8.0",
+        "@emotion/memoize": "^0.7.4",
+        "@emotion/unitless": "^0.7.5",
+        "@emotion/utils": "^1.0.0",
+        "csstype": "^3.0.2"
+      }
+    },
+    "@emotion/sheet": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.1.1.tgz",
+      "integrity": "sha512-J3YPccVRMiTZxYAY0IOq3kd+hUP8idY8Kz6B/Cyo+JuXq52Ek+zbPbSQUrVQp95aJ+lsAW7DPL1P2Z+U1jGkKA=="
+    },
+    "@emotion/styled": {
+      "version": "11.8.1",
+      "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.8.1.tgz",
+      "integrity": "sha512-OghEVAYBZMpEquHZwuelXcRjRJQOVayvbmNR0zr174NHdmMgrNkLC6TljKC5h9lZLkN5WGrdUcrKlOJ4phhoTQ==",
+      "optional": true,
+      "peer": true,
+      "requires": {
+        "@babel/runtime": "^7.13.10",
+        "@emotion/babel-plugin": "^11.7.1",
+        "@emotion/is-prop-valid": "^1.1.2",
+        "@emotion/serialize": "^1.0.2",
+        "@emotion/utils": "^1.1.0"
+      }
+    },
+    "@emotion/unitless": {
+      "version": "0.7.5",
+      "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz",
+      "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg=="
+    },
+    "@emotion/utils": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.1.0.tgz",
+      "integrity": "sha512-iRLa/Y4Rs5H/f2nimczYmS5kFJEbpiVvgN3XVfZ022IYhuNA1IRSHEizcof88LtCTXtl9S2Cxt32KgaXEu72JQ=="
+    },
+    "@emotion/weak-memoize": {
+      "version": "0.2.5",
+      "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz",
+      "integrity": "sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA=="
+    },
+    "@eslint/eslintrc": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.2.1.tgz",
+      "integrity": "sha512-bxvbYnBPN1Gibwyp6NrpnFzA3YtRL3BBAyEAFVIpNTm2Rn4Vy87GA5M4aSn3InRrlsbX5N0GW7XIx+U4SAEKdQ==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.4",
+        "debug": "^4.3.2",
+        "espree": "^9.3.1",
+        "globals": "^13.9.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.2.1",
+        "js-yaml": "^4.1.0",
+        "minimatch": "^3.0.4",
+        "strip-json-comments": "^3.1.1"
+      },
+      "dependencies": {
+        "globals": {
+          "version": "13.13.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz",
+          "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==",
+          "dev": true,
+          "requires": {
+            "type-fest": "^0.20.2"
+          }
+        },
+        "type-fest": {
+          "version": "0.20.2",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+          "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+          "dev": true
+        }
+      }
+    },
+    "@fontsource/roboto": {
+      "version": "4.5.3",
+      "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-4.5.3.tgz",
+      "integrity": "sha512-NUvBTj332dFRdiVkLlavXbDGoD2zyyeGYmMyrXOnctg/3e4pq95+rJgNfUP+k4v8UBk2L1aomGw9dDjbRdAmTg=="
+    },
+    "@humanwhocodes/config-array": {
+      "version": "0.9.5",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.9.5.tgz",
+      "integrity": "sha512-ObyMyWxZiCu/yTisA7uzx81s40xR2fD5Cg/2Kq7G02ajkNubJf6BopgDTmDyc3U7sXpNKM8cYOw7s7Tyr+DnCw==",
+      "dev": true,
+      "requires": {
+        "@humanwhocodes/object-schema": "^1.2.1",
+        "debug": "^4.1.1",
+        "minimatch": "^3.0.4"
+      }
+    },
+    "@humanwhocodes/object-schema": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz",
+      "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
+      "dev": true
+    },
+    "@istanbuljs/load-nyc-config": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
+      "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==",
+      "dev": true,
+      "requires": {
+        "camelcase": "^5.3.1",
+        "find-up": "^4.1.0",
+        "get-package-type": "^0.1.0",
+        "js-yaml": "^3.13.1",
+        "resolve-from": "^5.0.0"
+      },
+      "dependencies": {
+        "argparse": {
+          "version": "1.0.10",
+          "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
+          "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
+          "dev": true,
+          "requires": {
+            "sprintf-js": "~1.0.2"
+          }
+        },
+        "camelcase": {
+          "version": "5.3.1",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+          "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+          "dev": true
+        },
+        "js-yaml": {
+          "version": "3.14.1",
+          "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
+          "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
+          "dev": true,
+          "requires": {
+            "argparse": "^1.0.7",
+            "esprima": "^4.0.0"
+          }
+        },
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        }
+      }
+    },
+    "@istanbuljs/schema": {
+      "version": "0.1.3",
+      "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
+      "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
+      "dev": true
+    },
+    "@jest/console": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/console/-/console-27.5.1.tgz",
+      "integrity": "sha512-kZ/tNpS3NXn0mlXXXPNuDZnb4c0oZ20r4K5eemM2k30ZC3G0T02nXUvyhf5YdbXWHPEJLc9qGLxEZ216MdL+Zg==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "jest-message-util": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "slash": "^3.0.0"
+      }
+    },
+    "@jest/core": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/core/-/core-27.5.1.tgz",
+      "integrity": "sha512-AK6/UTrvQD0Cd24NSqmIA6rKsu0tKIxfiCducZvqxYdmMisOYAsdItspT+fQDQYARPf8XgjAFZi0ogW2agH5nQ==",
+      "dev": true,
+      "requires": {
+        "@jest/console": "^27.5.1",
+        "@jest/reporters": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "ansi-escapes": "^4.2.1",
+        "chalk": "^4.0.0",
+        "emittery": "^0.8.1",
+        "exit": "^0.1.2",
+        "graceful-fs": "^4.2.9",
+        "jest-changed-files": "^27.5.1",
+        "jest-config": "^27.5.1",
+        "jest-haste-map": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-resolve-dependencies": "^27.5.1",
+        "jest-runner": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "jest-watcher": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "rimraf": "^3.0.0",
+        "slash": "^3.0.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "@jest/environment": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-27.5.1.tgz",
+      "integrity": "sha512-/WQjhPJe3/ghaol/4Bq480JKXV/Rfw8nQdN7f41fM8VDHLcxKXou6QyXAh3EFr9/bVG3x74z1NWDkP87EiY8gA==",
+      "dev": true,
+      "requires": {
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "jest-mock": "^27.5.1"
+      }
+    },
+    "@jest/fake-timers": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-27.5.1.tgz",
+      "integrity": "sha512-/aPowoolwa07k7/oM3aASneNeBGCmGQsc3ugN4u6s4C/+s5M64MFo/+djTdiwcbQlRfFElGuDXWzaWj6QgKObQ==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "@sinonjs/fake-timers": "^8.0.1",
+        "@types/node": "*",
+        "jest-message-util": "^27.5.1",
+        "jest-mock": "^27.5.1",
+        "jest-util": "^27.5.1"
+      }
+    },
+    "@jest/globals": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-27.5.1.tgz",
+      "integrity": "sha512-ZEJNB41OBQQgGzgyInAv0UUfDDj3upmHydjieSxFvTRuZElrx7tXg/uVQ5hYVEwiXs3+aMsAeEc9X7xiSKCm4Q==",
+      "dev": true,
+      "requires": {
+        "@jest/environment": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "expect": "^27.5.1"
+      }
+    },
+    "@jest/reporters": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-27.5.1.tgz",
+      "integrity": "sha512-cPXh9hWIlVJMQkVk84aIvXuBB4uQQmFqZiacloFuGiP3ah1sbCxCosidXFDfqG8+6fO1oR2dTJTlsOy4VFmUfw==",
+      "dev": true,
+      "requires": {
+        "@bcoe/v8-coverage": "^0.2.3",
+        "@jest/console": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "collect-v8-coverage": "^1.0.0",
+        "exit": "^0.1.2",
+        "glob": "^7.1.2",
+        "graceful-fs": "^4.2.9",
+        "istanbul-lib-coverage": "^3.0.0",
+        "istanbul-lib-instrument": "^5.1.0",
+        "istanbul-lib-report": "^3.0.0",
+        "istanbul-lib-source-maps": "^4.0.0",
+        "istanbul-reports": "^3.1.3",
+        "jest-haste-map": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-worker": "^27.5.1",
+        "slash": "^3.0.0",
+        "source-map": "^0.6.0",
+        "string-length": "^4.0.1",
+        "terminal-link": "^2.0.0",
+        "v8-to-istanbul": "^8.1.0"
+      }
+    },
+    "@jest/source-map": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-27.5.1.tgz",
+      "integrity": "sha512-y9NIHUYF3PJRlHk98NdC/N1gl88BL08aQQgu4k4ZopQkCw9t9cV8mtl3TV8b/YCB8XaVTFrmUTAJvjsntDireg==",
+      "dev": true,
+      "requires": {
+        "callsites": "^3.0.0",
+        "graceful-fs": "^4.2.9",
+        "source-map": "^0.6.0"
+      }
+    },
+    "@jest/test-result": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-27.5.1.tgz",
+      "integrity": "sha512-EW35l2RYFUcUQxFJz5Cv5MTOxlJIQs4I7gxzi2zVU7PJhOwfYq1MdC5nhSmYjX1gmMmLPvB3sIaC+BkcHRBfag==",
+      "dev": true,
+      "requires": {
+        "@jest/console": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/istanbul-lib-coverage": "^2.0.0",
+        "collect-v8-coverage": "^1.0.0"
+      }
+    },
+    "@jest/test-sequencer": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-27.5.1.tgz",
+      "integrity": "sha512-LCheJF7WB2+9JuCS7VB/EmGIdQuhtqjRNI9A43idHv3E4KltCTsPsLxvdaubFHSYwY/fNjMWjl6vNRhDiN7vpQ==",
+      "dev": true,
+      "requires": {
+        "@jest/test-result": "^27.5.1",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-runtime": "^27.5.1"
+      }
+    },
+    "@jest/transform": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-27.5.1.tgz",
+      "integrity": "sha512-ipON6WtYgl/1329g5AIJVbUuEh0wZVbdpGwC99Jw4LwuoBNS95MVphU6zOeD9pDkon+LLbFL7lOQRapbB8SCHw==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.1.0",
+        "@jest/types": "^27.5.1",
+        "babel-plugin-istanbul": "^6.1.1",
+        "chalk": "^4.0.0",
+        "convert-source-map": "^1.4.0",
+        "fast-json-stable-stringify": "^2.0.0",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "pirates": "^4.0.4",
+        "slash": "^3.0.0",
+        "source-map": "^0.6.1",
+        "write-file-atomic": "^3.0.0"
+      }
+    },
+    "@jest/types": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/@jest/types/-/types-27.5.1.tgz",
+      "integrity": "sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==",
+      "dev": true,
+      "requires": {
+        "@types/istanbul-lib-coverage": "^2.0.0",
+        "@types/istanbul-reports": "^3.0.0",
+        "@types/node": "*",
+        "@types/yargs": "^16.0.0",
+        "chalk": "^4.0.0"
+      }
+    },
+    "@jridgewell/gen-mapping": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz",
+      "integrity": "sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@jridgewell/set-array": "^1.0.1",
+        "@jridgewell/sourcemap-codec": "^1.4.10",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "@jridgewell/resolve-uri": {
+      "version": "3.0.5",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.0.5.tgz",
+      "integrity": "sha512-VPeQ7+wH0itvQxnG+lIzWgkysKIr3L9sslimFW55rHMdGu/qCQ5z5h9zq4gI8uBtqkpHhsF4Z/OwExufUCThew=="
+    },
+    "@jridgewell/set-array": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz",
+      "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==",
+      "dev": true,
+      "peer": true
+    },
+    "@jridgewell/source-map": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.2.tgz",
+      "integrity": "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@jridgewell/gen-mapping": "^0.3.0",
+        "@jridgewell/trace-mapping": "^0.3.9"
+      }
+    },
+    "@jridgewell/sourcemap-codec": {
+      "version": "1.4.11",
+      "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.11.tgz",
+      "integrity": "sha512-Fg32GrJo61m+VqYSdRSjRXMjQ06j8YIYfcTqndLYVAaHmroZHLJZCydsWBOTDqXS2v+mjxohBWEMfg97GXmYQg=="
+    },
+    "@jridgewell/trace-mapping": {
+      "version": "0.3.14",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz",
+      "integrity": "sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ==",
+      "requires": {
+        "@jridgewell/resolve-uri": "^3.0.3",
+        "@jridgewell/sourcemap-codec": "^1.4.10"
+      }
+    },
+    "@lit/reactive-element": {
+      "version": "1.0.0-rc.4",
+      "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.0-rc.4.tgz",
+      "integrity": "sha512-dJMha+4NFYdpnUJzRrWTFV5Hdp9QHWFuPnaoqonrKl4lGJVnYez9mu8ev9F/5KM47tjAjh22DuRHrdFDHfOijA=="
+    },
+    "@material/animation": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/animation/-/animation-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-OjxWJYSRNs4vnPe8NclaNn+TsNc8TR/wHusGtezF5F+wl+5mh+K69BMXAmURtq3idoRg4XaOSC/Ohk1ovD1fMQ==",
+      "requires": {
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/base": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/base/-/base-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-vy5SQt+jcwwdRFfBvtpVdpULUBujecVUKOXcopaQoi2XIzI5EBHuR4gPN0cd1yfmVEucD6p2fvVv2FJ3Ngr61w==",
+      "requires": {
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/button": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/button/-/button-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-DB0MAvdIGWKuFwlQ57hjv7ZuHIioT2mnG7RWtL7ZoCWoY45nCrsbJirmX5zZFipm9gIOJ3YnIkIrUyMVSrDX+g==",
+      "requires": {
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/tokens": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/circular-progress": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/circular-progress/-/circular-progress-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-Gi6Ika8MEZQOT3Qei2NfTj+sRWxCDFjchPM7szNjIKgL2DyH03bHmodQFVcyBFiPWEcWMc/mqVYgGf/XJXs85w==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/progress-indicator": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/density": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/density/-/density-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-zOR5wISqPVr8KS/ERNC1jdRV9O832lzclyS9Ea20rDrWfuOiYsQ9bbIk12xWlxpgsn7r9fxQJyd1O2SURoHdRA==",
+      "requires": {
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/dialog": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/dialog/-/dialog-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-NfQR0fmNS/y2iRAx5YeODLLywBAnSyZI/CL9GUq4NiNj+FeSxe+5bhG1p9NxHeGMjEVrl6fG5L9ql7lqtfQaYQ==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/button": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/icon-button": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/tokens": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/dom": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/dom/-/dom-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-iUpZG6Bb2l/PfNV2Fb/pXfG1p4Bz4PC9A7ATPlKfcU5HioObcnYVc/+Hrtaw8eu28BNIc+VVROtbfpqG/YgKSQ==",
+      "requires": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/elevation": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/elevation/-/elevation-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-AqN/tsTGGyBzZ7CtoSMBY9bDYvCuUt98EUfiGjZGXcf4HgoHV3Cn/JSLrhru5Cq8Nx6HF6AmHh3dQCfNCQduew==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/feature-targeting": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/feature-targeting/-/feature-targeting-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-CrVoGNu0ym52OPEKy3kgeNL2oSWOCBYbYxSH3GhERxCq5FwGBN+XmK/ZDLFVQlHYy3v8x4TqVEwXviCeumNTxQ==",
+      "requires": {
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/floating-label": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/floating-label/-/floating-label-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-Cp0/LngkW6/uZWbEDTe3Ox143V4kYtxl9twiM3XLKd6a67JHCzneQWFzC0qSg90b3r5O+1zOkT3ZMF2Pbu2Vwg==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/form-field": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/form-field/-/form-field-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-NCc/o60gwuF28PVMgFkHrKcHxIaCMZK9JRVfoaD0sF2BINYrjaCkFZ+x6AhNjAWLUQMhJMfc+1WXAUE2T85Mug==",
+      "requires": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/icon-button": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/icon-button/-/icon-button-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-9P6cjRqKtjE6ML+r5yz0ExU/f2KLdNabHQxmO6RpKd/FnjTyP1NcWqqj8dsvo/DZ7mOtT1MIThgkQDdiMqcYLg==",
+      "requires": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/line-ripple": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/line-ripple/-/line-ripple-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-LlyiyxpHNlFt0PZ8Q2tvOPbjNcgm3L7tUebXsM7iGyoKXfj0HwyDI31S0KgtU3Vs5DIK4U4mnRWtoAxtBW6Jfg==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/list": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/list/-/list-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-bjHXXk2ZeWxAFs4cJxy5J5A5ClUd3FGjRv/LwCYpsh7Dm7e8kSe8Lw2MWb6FXyF3mDJM6xqN3xXQWOh6UEu5wA==",
+      "requires": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/menu": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/menu/-/menu-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-x59UHoTLvEsPKjFdffrKTgEyc0T4W3m58RsizAmapXr59Uthq8+PTFOkAv9R1PV/ZCzxay7Vx+QcekC4qOr40A==",
+      "requires": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/list": "14.0.0-canary.261f2db59.0",
+        "@material/menu-surface": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/menu-surface": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/menu-surface/-/menu-surface-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-zv/fv/W3zdSb+c/p6GNcOqA3+wAc/r8MOtV53WJPLlvZZSpGoTwHUp+GPiNeovfbsTSxN95XOXuVQBEfKEb8vA==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-base": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-base/-/mwc-base-0.25.3.tgz",
+      "integrity": "sha512-4wvxZ9dhPr0O4jjOHPmFyn77pafe+h1gHPlT9sbQ+ly8NY/fSn/TXn7/PbxgL8g4ZHxMvD3o7PJopg+6cbHp8Q==",
+      "requires": {
+        "@lit/reactive-element": "1.0.0-rc.4",
+        "@material/base": "=14.0.0-canary.261f2db59.0",
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-button": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-button/-/mwc-button-0.25.3.tgz",
+      "integrity": "sha512-usHEKchj9hqetY7n0yebTz1Pk9Z+9W/sNZheFoSaiWQCv9XhtCdKkHH0MXTv8SpwxWuEKUf/XjtyvikGIcIn7w==",
+      "requires": {
+        "@material/mwc-icon": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-checkbox": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-checkbox/-/mwc-checkbox-0.25.3.tgz",
+      "integrity": "sha512-PSh9IAgQK4XiDzBwgclheejkA4cbZ3K9V1JTTl/YVRDD/OLLM+Bh8tbnAg/1kGVlPWOUfDrYCcZ0gg472ca7gw==",
+      "requires": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-circular-progress": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-circular-progress/-/mwc-circular-progress-0.25.3.tgz",
+      "integrity": "sha512-ajgSzfdRfq0/sZg0Z5W/ZpgZwD8Ioj59m5ScCPXXdkRoVHf7+8lsD/2Fh4095GfoYE4PWSkXYVlWsQCx+aJbcA==",
+      "requires": {
+        "@material/circular-progress": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/theme": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-dialog": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-dialog/-/mwc-dialog-0.25.3.tgz",
+      "integrity": "sha512-UpxAYAzKXO1MW4ezpiYfEQgov08p0J8KDVKqKrMwg7lsZRkAtUMk4YJkM6qmWGqGPqd/cN++42PMPHAISJH3yA==",
+      "requires": {
+        "@material/dialog": "=14.0.0-canary.261f2db59.0",
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-button": "^0.25.3",
+        "blocking-elements": "^0.1.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1",
+        "wicg-inert": "^3.0.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-floating-label": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-floating-label/-/mwc-floating-label-0.25.3.tgz",
+      "integrity": "sha512-3uFMi8Y680P0nzP5zih4YuOZJLl/C6Ux9G810Unwo44zblG/ckgJlFiM+T+oR+OH5KM8LbfNlV0ypo7FT5zYJA==",
+      "requires": {
+        "@material/floating-label": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-formfield": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-formfield/-/mwc-formfield-0.25.3.tgz",
+      "integrity": "sha512-JP/ZgsWok0ZVwUQfYgaov0Ocn1zDiiw7Po6q8k/n5tOS67S41XUB/ctiUg1gh00LAM0v3eZAexa9ZmKarviVJA==",
+      "requires": {
+        "@material/form-field": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-icon": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-icon/-/mwc-icon-0.25.3.tgz",
+      "integrity": "sha512-36076AWZIRSr8qYOLjuDDkxej/HA0XAosrj7TS1ZeLlUBnLUtbDtvc1S7KSa0hqez7ouzOqGaWK24yoNnTa2OA==",
+      "requires": {
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-line-ripple": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-line-ripple/-/mwc-line-ripple-0.25.3.tgz",
+      "integrity": "sha512-ANJzSyumb+shBVTIhqF1+YByPU/EpFXxI9CS26qThFqlUDpYXg5xcoZpkMSmZv3Wv/loF1rs2mJfFWOcC6nFnw==",
+      "requires": {
+        "@material/line-ripple": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-list": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-list/-/mwc-list-0.25.3.tgz",
+      "integrity": "sha512-2T297qVaQsKv+QDNP2ag9g04RLKO1tm2F6BwwqvdbXTsY+LKYOJe2/aSe0kX2tQLayX4ydy2RnTevo9Ld+c+4g==",
+      "requires": {
+        "@material/base": "=14.0.0-canary.261f2db59.0",
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/list": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-checkbox": "^0.25.3",
+        "@material/mwc-radio": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-menu": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-menu/-/mwc-menu-0.25.3.tgz",
+      "integrity": "sha512-jr5R61BfqrJC0lsAI63y4BsEM2eY3n6kiCy2ZnwinmxrfFrS709T/zuSUUW/xG9b9inSku4WjjSkDhPzQrmS3g==",
+      "requires": {
+        "@material/menu": "=14.0.0-canary.261f2db59.0",
+        "@material/menu-surface": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-list": "^0.25.3",
+        "@material/shape": "=14.0.0-canary.261f2db59.0",
+        "@material/theme": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-notched-outline": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-notched-outline/-/mwc-notched-outline-0.25.3.tgz",
+      "integrity": "sha512-8jvU8GD0Pke+pfTQ0PdXpZmkU3XIHhMVY6AHM/2IQrXHkVZmAm9kbwL7ne3Ao+6f5n+DeXDGd+SG9U6ZZjD7gw==",
+      "requires": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/notched-outline": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-radio": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-radio/-/mwc-radio-0.25.3.tgz",
+      "integrity": "sha512-SXpVDrsQnz7+2w/kfBxcOJ4P+uJ0RxBd9mCLE7wVyN53gDLkNHqA0npdl2PNpRaaMavVrt27L8wWo5QIT+7zWA==",
+      "requires": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "@material/radio": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-ripple": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-ripple/-/mwc-ripple-0.25.3.tgz",
+      "integrity": "sha512-G/gt/csxgME6/sAku3GiuB0O2LLvoPWsRTLq/9iABpaGLJjqaKHvNg/IVzNDdF3YZT7EORgR9cBWWl7umA4i4Q==",
+      "requires": {
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/ripple": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-select": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-select/-/mwc-select-0.25.3.tgz",
+      "integrity": "sha512-mf1WrsNAW4rDHeVH+AgTPfNHAg70dJdwuIfIBqksAty3pYxnXQ9RjpL4Z/7kLdsGiS44du65vVgmZ63T0ifugQ==",
+      "requires": {
+        "@material/dom": "=14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "=14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "=14.0.0-canary.261f2db59.0",
+        "@material/list": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-floating-label": "^0.25.3",
+        "@material/mwc-icon": "^0.25.3",
+        "@material/mwc-line-ripple": "^0.25.3",
+        "@material/mwc-list": "^0.25.3",
+        "@material/mwc-menu": "^0.25.3",
+        "@material/mwc-notched-outline": "^0.25.3",
+        "@material/select": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-snackbar": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-snackbar/-/mwc-snackbar-0.25.3.tgz",
+      "integrity": "sha512-DJyWQl1rksv502qLQta81YQ3q3iy0GlVQcXZq88nBG9o64070qZW92rfZmiQ63MRwGbdNmrUFZ3QBoClY1JpFg==",
+      "requires": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/snackbar": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-switch": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-switch/-/mwc-switch-0.25.3.tgz",
+      "integrity": "sha512-cjppRf17q70SdtTP0twMAzODJY7ztJFnfDDZKM5N72F4cp2q0VvhIU42hfBCGLIEbXPQBCLG0dxqt2Mo04qCcA==",
+      "requires": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-ripple": "^0.25.3",
+        "@material/switch": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-textarea": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-textarea/-/mwc-textarea-0.25.3.tgz",
+      "integrity": "sha512-u3PkwAL6+2DGr4rxrDAqBPBCwFX40lM8/ZKgQ9mg7xLB6Rhz/5n3Sf5MtMwGSJO0ZU5CGqU3qY9x21S4tM/Xhw==",
+      "requires": {
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-textfield": "^0.25.3",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/mwc-textfield": {
+      "version": "0.25.3",
+      "resolved": "https://registry.npmjs.org/@material/mwc-textfield/-/mwc-textfield-0.25.3.tgz",
+      "integrity": "sha512-stpZ8sEyo2Mb9fG2XCoTc1Kom8oRXZiVI5rU88GtfcBU7nH0em8S4grq9X1mVfUG6Cfi1G/T+avCSIhzbYtr0w==",
+      "requires": {
+        "@material/floating-label": "=14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "=14.0.0-canary.261f2db59.0",
+        "@material/mwc-base": "^0.25.3",
+        "@material/mwc-floating-label": "^0.25.3",
+        "@material/mwc-line-ripple": "^0.25.3",
+        "@material/mwc-notched-outline": "^0.25.3",
+        "@material/textfield": "=14.0.0-canary.261f2db59.0",
+        "lit": "^2.0.0",
+        "tslib": "^2.0.1"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/notched-outline": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/notched-outline/-/notched-outline-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-gtn+IKAiX2rbfbX3a9aDlfUoKCEYrlAPOZifKXUaZ4UJYMNLzZuAqy7l5Ds30emtqUE22mySTEWqhzK6dePKsA==",
+      "requires": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/progress-indicator": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/progress-indicator/-/progress-indicator-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-qm+zUMvFYhHuVB2OdgWTO/Dv1hMFEdIT3loX5OJMpvQ66l6rez/3F7blwHkm6W4mfuxRS3zdDdYbP5QdFcuHuA==",
+      "requires": {
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/radio": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/radio/-/radio-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-AvrsOqhP8UZ5d58RWgaTmQVlWQRULwk2BXhsEhtxz56CmTsyVM49thNbaNnc/TzuY9Ssxv/L2wYVbR2B3BX9Yw==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/touch-target": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/ripple": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/ripple/-/ripple-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-3FLCLj8X7KrFfuYBHJg1b7Odb3V/AW7fxk3m1i1zhDnygKmlQ/abVucH1s2qbX3Y+JIiq+5/C5407h9BFtOf+A==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/rtl": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/rtl/-/rtl-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-bVnXBbUsHs57+EXdeFbcwaKy3lT/itI/qTLmJ88ar0qaGEujO1GmESHm3ioqkeo4kQpTfDhBwQGeEi1aDaTdFg==",
+      "requires": {
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/select": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/select/-/select-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-r/D3e75H/sg+7iv+dkiyQ9cg8R6koHQJl85/gZqOlHpaQGSH5gSxpVeILkRY+ic6obQTdQCPRvUi9kzUve5zEg==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "14.0.0-canary.261f2db59.0",
+        "@material/list": "14.0.0-canary.261f2db59.0",
+        "@material/menu": "14.0.0-canary.261f2db59.0",
+        "@material/menu-surface": "14.0.0-canary.261f2db59.0",
+        "@material/notched-outline": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/shape": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/shape/-/shape-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-VjcQltd1uF9ugvLExMy00SMISjy/370o8lsZlb1T+xHyhXHL3UxeuWYLW5Amq6mbx65+c9Df9WmlXXOdebpEkw==",
+      "requires": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/snackbar": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/snackbar/-/snackbar-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-RLxO0dWBmhU+3y/PCYN0oiQUvzw8cdeFLmiUN9BPn2unwmTPp5nUdaTde7TQ93vRNidyPtDnkEFnflunDCk2Ew==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/button": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/icon-button": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/switch": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/switch/-/switch-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-WoHxAeTVh43OAwkdC9uWI5caVwCCn0JrxMbPYAonbuoGAn/blXECuDtSpXD3m+05RwSgUHlX9n14nb3SGQMOYw==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/elevation": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/tokens": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/textfield": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/textfield/-/textfield-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-KBPgpvvVFBfLx9nc6+wWOS2hJ40JVwh5KBjMoYbiOEFLf0O7SgCAVREHaFAXrPsC8AeTyUipx6TReONIGfMCPQ==",
+      "requires": {
+        "@material/animation": "14.0.0-canary.261f2db59.0",
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/density": "14.0.0-canary.261f2db59.0",
+        "@material/dom": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/floating-label": "14.0.0-canary.261f2db59.0",
+        "@material/line-ripple": "14.0.0-canary.261f2db59.0",
+        "@material/notched-outline": "14.0.0-canary.261f2db59.0",
+        "@material/ripple": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "@material/shape": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "@material/typography": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/theme": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/theme/-/theme-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-bUqyFT0QF8Nxx02fekt3CXIfC9DEPOPdo2hjgdtvhrNP+vftbkI2tKZ5/uRUnVA+zqQAOyIl5z6FOMg4fyemCA==",
+      "requires": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/tokens": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/tokens/-/tokens-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-mgar9gsLv00HTvXIDvNR1vEEXpfKgeWhVTO8a7aWofSNyENNOVc5ImJwBgCAMb5SgLHBi6w8/c1tPzjOewBfCA==",
+      "requires": {
+        "@material/elevation": "14.0.0-canary.261f2db59.0"
+      }
+    },
+    "@material/touch-target": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/touch-target/-/touch-target-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-xA6TTHN7aOTXg/+c6mQJlogzTD+Sp8WPC5TK8RBXbQxEykGXGW15p+H9pG+rX/gzD5iehnHRBrDUFmAGoskhcQ==",
+      "requires": {
+        "@material/base": "14.0.0-canary.261f2db59.0",
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/rtl": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@material/typography": {
+      "version": "14.0.0-canary.261f2db59.0",
+      "resolved": "https://registry.npmjs.org/@material/typography/-/typography-14.0.0-canary.261f2db59.0.tgz",
+      "integrity": "sha512-WOCdcNkD5KBRAwICcRqWBRG3cDkyrwK5USTNmG0oxnwnZAN7daOpPTdLppVAhadE7faj8d67ON+V9pH7+T62FQ==",
+      "requires": {
+        "@material/feature-targeting": "14.0.0-canary.261f2db59.0",
+        "@material/theme": "14.0.0-canary.261f2db59.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "@mui/icons-material": {
+      "version": "5.8.4",
+      "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-5.8.4.tgz",
+      "integrity": "sha512-9Z/vyj2szvEhGWDvb+gG875bOGm8b8rlHBKOD1+nA3PcgC3fV6W1AU6pfOorPeBfH2X4mb9Boe97vHvaSndQvA==",
+      "requires": {
+        "@babel/runtime": "^7.17.2"
+      }
+    },
+    "@mui/lab": {
+      "version": "5.0.0-alpha.92",
+      "resolved": "https://registry.npmjs.org/@mui/lab/-/lab-5.0.0-alpha.92.tgz",
+      "integrity": "sha512-lVBhx6XDKzY9kqZa0iHQyRqRYtAS/EURJsvvkOrIF9xrEwcfyzTJQoR1eWW/7i6FtlEALeKOZEt9usXSpKs8FA==",
+      "requires": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/base": "5.0.0-alpha.91",
+        "@mui/system": "^5.9.2",
+        "@mui/utils": "^5.9.1",
+        "clsx": "^1.2.1",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0"
+      },
+      "dependencies": {
+        "@mui/base": {
+          "version": "5.0.0-alpha.91",
+          "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.91.tgz",
+          "integrity": "sha512-/W5amPDz+Lout4FtX5HOyx2Q+YL/EtZciFrx2DDRuUm4M/pWnjfDZAtM+0aqimEvuk3FU+/PuFc7IAyhCSX4Cg==",
+          "requires": {
+            "@babel/runtime": "^7.17.2",
+            "@emotion/is-prop-valid": "^1.1.3",
+            "@mui/types": "^7.1.5",
+            "@mui/utils": "^5.9.1",
+            "@popperjs/core": "^2.11.5",
+            "clsx": "^1.2.1",
+            "prop-types": "^15.8.1",
+            "react-is": "^18.2.0"
+          },
+          "dependencies": {
+            "@mui/types": {
+              "version": "7.1.5",
+              "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.5.tgz",
+              "integrity": "sha512-HnRXrxgHJYJcT8ZDdDCQIlqk0s0skOKD7eWs9mJgBUu70hyW4iA6Kiv3yspJR474RFH8hysKR65VVSzUSzkuwA==",
+              "requires": {}
+            }
+          }
+        },
+        "@mui/system": {
+          "version": "5.9.2",
+          "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.9.2.tgz",
+          "integrity": "sha512-iOvt9tVeFapHL7f7M6BSIiKGMx6RTRvAmc8ipMnQ/MR5Qsxwnyv7qKtNC/K11Rk13Xx0VPaPAhyvBcsr3KdpHA==",
+          "requires": {
+            "@babel/runtime": "^7.17.2",
+            "@mui/private-theming": "^5.9.1",
+            "@mui/styled-engine": "^5.8.7",
+            "@mui/types": "^7.1.5",
+            "@mui/utils": "^5.9.1",
+            "clsx": "^1.2.1",
+            "csstype": "^3.1.0",
+            "prop-types": "^15.8.1"
+          },
+          "dependencies": {
+            "@mui/private-theming": {
+              "version": "5.9.1",
+              "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.9.1.tgz",
+              "integrity": "sha512-eIh2IZJInNTdgPLMo9cruzm8UDX5amBBxxsSoNre7lRj3wcsu3TG5OKjIbzkf4VxHHEhdPeNNQyt92k7L78u2A==",
+              "requires": {
+                "@babel/runtime": "^7.17.2",
+                "@mui/utils": "^5.9.1",
+                "prop-types": "^15.8.1"
+              }
+            },
+            "@mui/styled-engine": {
+              "version": "5.8.7",
+              "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.8.7.tgz",
+              "integrity": "sha512-tVqtowjbYmiRq+qcqXK731L9eWoL9H8xTRhuTgaDGKdch1zlt4I2UwInUe1w2N9N/u3/jHsFbLcl1Un3uOwpQg==",
+              "requires": {
+                "@babel/runtime": "^7.17.2",
+                "@emotion/cache": "^11.9.3",
+                "csstype": "^3.1.0",
+                "prop-types": "^15.8.1"
+              }
+            },
+            "@mui/types": {
+              "version": "7.1.5",
+              "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.5.tgz",
+              "integrity": "sha512-HnRXrxgHJYJcT8ZDdDCQIlqk0s0skOKD7eWs9mJgBUu70hyW4iA6Kiv3yspJR474RFH8hysKR65VVSzUSzkuwA==",
+              "requires": {}
+            }
+          }
+        },
+        "@mui/utils": {
+          "version": "5.9.1",
+          "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.9.1.tgz",
+          "integrity": "sha512-8+4adOR3xusyJwvbnZxcjqcmbWvl7Og+260ZKIrSvwnFs0aLubL+8MhiceeDDGcmb0bTKxfUgRJ96j32Jb7P+A==",
+          "requires": {
+            "@babel/runtime": "^7.17.2",
+            "@types/prop-types": "^15.7.5",
+            "@types/react-is": "^16.7.1 || ^17.0.0",
+            "prop-types": "^15.8.1",
+            "react-is": "^18.2.0"
+          }
+        },
+        "react-is": {
+          "version": "18.2.0",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+          "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+        }
+      }
+    },
+    "@mui/material": {
+      "version": "5.9.2",
+      "resolved": "https://registry.npmjs.org/@mui/material/-/material-5.9.2.tgz",
+      "integrity": "sha512-FItBuj9bPdVier2g5OBG2HHlQLou4JuH3gdnY43tpJOrCpmWrbDVJZqrSufKJFO00qjvTYaGlJedIu+vXn79qw==",
+      "requires": {
+        "@babel/runtime": "^7.17.2",
+        "@mui/base": "5.0.0-alpha.91",
+        "@mui/system": "^5.9.2",
+        "@mui/types": "^7.1.5",
+        "@mui/utils": "^5.9.1",
+        "@types/react-transition-group": "^4.4.5",
+        "clsx": "^1.2.1",
+        "csstype": "^3.1.0",
+        "prop-types": "^15.8.1",
+        "react-is": "^18.2.0",
+        "react-transition-group": "^4.4.2"
+      },
+      "dependencies": {
+        "@mui/base": {
+          "version": "5.0.0-alpha.91",
+          "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-alpha.91.tgz",
+          "integrity": "sha512-/W5amPDz+Lout4FtX5HOyx2Q+YL/EtZciFrx2DDRuUm4M/pWnjfDZAtM+0aqimEvuk3FU+/PuFc7IAyhCSX4Cg==",
+          "requires": {
+            "@babel/runtime": "^7.17.2",
+            "@emotion/is-prop-valid": "^1.1.3",
+            "@mui/types": "^7.1.5",
+            "@mui/utils": "^5.9.1",
+            "@popperjs/core": "^2.11.5",
+            "clsx": "^1.2.1",
+            "prop-types": "^15.8.1",
+            "react-is": "^18.2.0"
+          }
+        },
+        "@mui/system": {
+          "version": "5.9.2",
+          "resolved": "https://registry.npmjs.org/@mui/system/-/system-5.9.2.tgz",
+          "integrity": "sha512-iOvt9tVeFapHL7f7M6BSIiKGMx6RTRvAmc8ipMnQ/MR5Qsxwnyv7qKtNC/K11Rk13Xx0VPaPAhyvBcsr3KdpHA==",
+          "requires": {
+            "@babel/runtime": "^7.17.2",
+            "@mui/private-theming": "^5.9.1",
+            "@mui/styled-engine": "^5.8.7",
+            "@mui/types": "^7.1.5",
+            "@mui/utils": "^5.9.1",
+            "clsx": "^1.2.1",
+            "csstype": "^3.1.0",
+            "prop-types": "^15.8.1"
+          },
+          "dependencies": {
+            "@mui/private-theming": {
+              "version": "5.9.1",
+              "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-5.9.1.tgz",
+              "integrity": "sha512-eIh2IZJInNTdgPLMo9cruzm8UDX5amBBxxsSoNre7lRj3wcsu3TG5OKjIbzkf4VxHHEhdPeNNQyt92k7L78u2A==",
+              "requires": {
+                "@babel/runtime": "^7.17.2",
+                "@mui/utils": "^5.9.1",
+                "prop-types": "^15.8.1"
+              }
+            },
+            "@mui/styled-engine": {
+              "version": "5.8.7",
+              "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-5.8.7.tgz",
+              "integrity": "sha512-tVqtowjbYmiRq+qcqXK731L9eWoL9H8xTRhuTgaDGKdch1zlt4I2UwInUe1w2N9N/u3/jHsFbLcl1Un3uOwpQg==",
+              "requires": {
+                "@babel/runtime": "^7.17.2",
+                "@emotion/cache": "^11.9.3",
+                "csstype": "^3.1.0",
+                "prop-types": "^15.8.1"
+              }
+            }
+          }
+        },
+        "@mui/types": {
+          "version": "7.1.5",
+          "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.1.5.tgz",
+          "integrity": "sha512-HnRXrxgHJYJcT8ZDdDCQIlqk0s0skOKD7eWs9mJgBUu70hyW4iA6Kiv3yspJR474RFH8hysKR65VVSzUSzkuwA==",
+          "requires": {}
+        },
+        "@mui/utils": {
+          "version": "5.9.1",
+          "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-5.9.1.tgz",
+          "integrity": "sha512-8+4adOR3xusyJwvbnZxcjqcmbWvl7Og+260ZKIrSvwnFs0aLubL+8MhiceeDDGcmb0bTKxfUgRJ96j32Jb7P+A==",
+          "requires": {
+            "@babel/runtime": "^7.17.2",
+            "@types/prop-types": "^15.7.5",
+            "@types/react-is": "^16.7.1 || ^17.0.0",
+            "prop-types": "^15.8.1",
+            "react-is": "^18.2.0"
+          }
+        },
+        "react-is": {
+          "version": "18.2.0",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz",
+          "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w=="
+        }
+      }
+    },
+    "@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      }
+    },
+    "@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true
+    },
+    "@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      }
+    },
+    "@popperjs/core": {
+      "version": "2.11.5",
+      "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.5.tgz",
+      "integrity": "sha512-9X2obfABZuDVLCgPK9aX0a/x4jaOEweTTWE2+9sr0Qqqevj2Uv5XorvusThmc9XGYpS9yI+fhh8RTafBtGposw=="
+    },
+    "@sinonjs/commons": {
+      "version": "1.8.3",
+      "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.3.tgz",
+      "integrity": "sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==",
+      "dev": true,
+      "requires": {
+        "type-detect": "4.0.8"
+      }
+    },
+    "@sinonjs/fake-timers": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-8.1.0.tgz",
+      "integrity": "sha512-OAPJUAtgeINhh/TAlUID4QTs53Njm7xzddaVlEs/SXwgtiD1tW22zAB/W1wdqfrpmikgaWQ9Fw6Ws+hsiRm5Vg==",
+      "dev": true,
+      "requires": {
+        "@sinonjs/commons": "^1.7.0"
+      }
+    },
+    "@testing-library/dom": {
+      "version": "8.11.3",
+      "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.11.3.tgz",
+      "integrity": "sha512-9LId28I+lx70wUiZjLvi1DB/WT2zGOxUh46glrSNMaWVx849kKAluezVzZrXJfTKKoQTmEOutLes/bHg4Bj3aA==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.10.4",
+        "@babel/runtime": "^7.12.5",
+        "@types/aria-query": "^4.2.0",
+        "aria-query": "^5.0.0",
+        "chalk": "^4.1.0",
+        "dom-accessibility-api": "^0.5.9",
+        "lz-string": "^1.4.4",
+        "pretty-format": "^27.0.2"
+      }
+    },
+    "@testing-library/jest-dom": {
+      "version": "5.16.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.16.2.tgz",
+      "integrity": "sha512-6ewxs1MXWwsBFZXIk4nKKskWANelkdUehchEOokHsN8X7c2eKXGw+77aRV63UU8f/DTSVUPLaGxdrj4lN7D/ug==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.9.2",
+        "@types/testing-library__jest-dom": "^5.9.1",
+        "aria-query": "^5.0.0",
+        "chalk": "^3.0.0",
+        "css": "^3.0.0",
+        "css.escape": "^1.5.1",
+        "dom-accessibility-api": "^0.5.6",
+        "lodash": "^4.17.15",
+        "redent": "^3.0.0"
+      },
+      "dependencies": {
+        "chalk": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
+          "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.1.0",
+            "supports-color": "^7.1.0"
+          }
+        },
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "@testing-library/react": {
+      "version": "12.1.4",
+      "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.4.tgz",
+      "integrity": "sha512-jiPKOm7vyUw311Hn/HlNQ9P8/lHNtArAx0PisXyFixDDvfl8DbD6EUdbshK5eqauvBSvzZd19itqQ9j3nferJA==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@testing-library/dom": "^8.0.0",
+        "@types/react-dom": "*"
+      }
+    },
+    "@testing-library/react-hooks": {
+      "version": "7.0.2",
+      "resolved": "https://registry.npmjs.org/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz",
+      "integrity": "sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "@types/react": ">=16.9.0",
+        "@types/react-dom": ">=16.9.0",
+        "@types/react-test-renderer": ">=16.9.0",
+        "react-error-boundary": "^3.1.0"
+      }
+    },
+    "@testing-library/user-event": {
+      "version": "13.5.0",
+      "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-13.5.0.tgz",
+      "integrity": "sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5"
+      }
+    },
+    "@tootallnate/once": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
+      "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==",
+      "dev": true
+    },
+    "@tsconfig/node10": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.8.tgz",
+      "integrity": "sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "@tsconfig/node12": {
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.9.tgz",
+      "integrity": "sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "@tsconfig/node14": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz",
+      "integrity": "sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "@tsconfig/node16": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.2.tgz",
+      "integrity": "sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "@types/aria-query": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-4.2.2.tgz",
+      "integrity": "sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==",
+      "dev": true
+    },
+    "@types/babel__core": {
+      "version": "7.1.18",
+      "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.18.tgz",
+      "integrity": "sha512-S7unDjm/C7z2A2R9NzfKCK1I+BAALDtxEmsJBwlB3EzNfb929ykjL++1CK9LO++EIp2fQrC8O+BwjKvz6UeDyQ==",
+      "dev": true,
+      "requires": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0",
+        "@types/babel__generator": "*",
+        "@types/babel__template": "*",
+        "@types/babel__traverse": "*"
+      }
+    },
+    "@types/babel__generator": {
+      "version": "7.6.4",
+      "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.4.tgz",
+      "integrity": "sha512-tFkciB9j2K755yrTALxD44McOrk+gfpIpvC3sxHjRawj6PfnQxrse4Clq5y/Rq+G3mrBurMax/lG8Qn2t9mSsg==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "@types/babel__template": {
+      "version": "7.4.1",
+      "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.1.tgz",
+      "integrity": "sha512-azBFKemX6kMg5Io+/rdGT0dkGreboUVR0Cdm3fz9QJWpaQGJRQXl7C+6hOTCZcMll7KFyEQpgbYI2lHdsS4U7g==",
+      "dev": true,
+      "requires": {
+        "@babel/parser": "^7.1.0",
+        "@babel/types": "^7.0.0"
+      }
+    },
+    "@types/babel__traverse": {
+      "version": "7.14.2",
+      "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.14.2.tgz",
+      "integrity": "sha512-K2waXdXBi2302XUdcHcR1jCeU0LL4TD9HRs/gk0N2Xvrht+G/BfJa4QObBQZfhMdxiCpV3COl5Nfq4uKTeTnJA==",
+      "dev": true,
+      "requires": {
+        "@babel/types": "^7.3.0"
+      }
+    },
+    "@types/eslint": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-7.28.0.tgz",
+      "integrity": "sha512-07XlgzX0YJUn4iG1ocY4IX9DzKSmMGUs6ESKlxWhZRaa0fatIWaHWUVapcuGa8r5HFnTqzj+4OCjd5f7EZ/i/A==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@types/estree": "*",
+        "@types/json-schema": "*"
+      }
+    },
+    "@types/eslint-scope": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.1.tgz",
+      "integrity": "sha512-SCFeogqiptms4Fg29WpOTk5nHIzfpKCemSN63ksBQYKTcXoJEmJagV+DhVmbapZzY4/5YaOV1nZwrsU79fFm1g==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@types/eslint": "*",
+        "@types/estree": "*"
+      }
+    },
+    "@types/estree": {
+      "version": "0.0.50",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.50.tgz",
+      "integrity": "sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==",
+      "dev": true,
+      "peer": true
+    },
+    "@types/graceful-fs": {
+      "version": "4.1.5",
+      "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.5.tgz",
+      "integrity": "sha512-anKkLmZZ+xm4p8JWBf4hElkM4XR+EZeA2M9BAkkTldmcyDY4mbdIJnRghDJH3Ov5ooY7/UAoENtmdMSkaAd7Cw==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
+    "@types/istanbul-lib-coverage": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz",
+      "integrity": "sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==",
+      "dev": true
+    },
+    "@types/istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==",
+      "dev": true,
+      "requires": {
+        "@types/istanbul-lib-coverage": "*"
+      }
+    },
+    "@types/istanbul-reports": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz",
+      "integrity": "sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==",
+      "dev": true,
+      "requires": {
+        "@types/istanbul-lib-report": "*"
+      }
+    },
+    "@types/jest": {
+      "version": "27.4.1",
+      "resolved": "https://registry.npmjs.org/@types/jest/-/jest-27.4.1.tgz",
+      "integrity": "sha512-23iPJADSmicDVrWk+HT58LMJtzLAnB2AgIzplQuq/bSrGaxCrlvRFjGbXmamnnk/mAmCdLStiGqggu28ocUyiw==",
+      "dev": true,
+      "requires": {
+        "jest-matcher-utils": "^27.0.0",
+        "pretty-format": "^27.0.0"
+      }
+    },
+    "@types/js-cookie": {
+      "version": "2.2.7",
+      "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.7.tgz",
+      "integrity": "sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA=="
+    },
+    "@types/json-schema": {
+      "version": "7.0.9",
+      "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.9.tgz",
+      "integrity": "sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==",
+      "dev": true
+    },
+    "@types/json5": {
+      "version": "0.0.29",
+      "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
+      "integrity": "sha1-7ihweulOEdK4J7y+UnC86n8+ce4=",
+      "dev": true
+    },
+    "@types/luxon": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-2.0.7.tgz",
+      "integrity": "sha512-AxiYycfO+/M4VIH0ribSr2iPFC+APewpJIaQSydwVnzorK3mjSFXkA3HmhQidGx44MpwaatFyEkbW/WD4zdDaQ==",
+      "dev": true
+    },
+    "@types/minimist": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz",
+      "integrity": "sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==",
+      "dev": true,
+      "peer": true
+    },
+    "@types/node": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.2.tgz",
+      "integrity": "sha512-JepeIUPFDARgIs0zD/SKPgFsJEAF0X5/qO80llx59gOxFTboS9Amv3S+QfB7lqBId5sFXJ99BN0J6zFRvL9dDA==",
+      "dev": true
+    },
+    "@types/normalize-package-data": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz",
+      "integrity": "sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==",
+      "dev": true,
+      "peer": true
+    },
+    "@types/parse-json": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
+      "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA=="
+    },
+    "@types/prettier": {
+      "version": "2.4.4",
+      "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.4.4.tgz",
+      "integrity": "sha512-ReVR2rLTV1kvtlWFyuot+d1pkpG2Fw/XKE3PDAdj57rbM97ttSp9JZ2UsP+2EHTylra9cUf6JA7tGwW1INzUrA==",
+      "dev": true
+    },
+    "@types/prop-types": {
+      "version": "15.7.5",
+      "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
+      "integrity": "sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w=="
+    },
+    "@types/react": {
+      "version": "17.0.39",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.39.tgz",
+      "integrity": "sha512-UVavlfAxDd/AgAacMa60Azl7ygyQNRwC/DsHZmKgNvPmRR5p70AJ5Q9EAmL2NWOJmeV+vVUI4IAP7GZrN8h8Ug==",
+      "requires": {
+        "@types/prop-types": "*",
+        "@types/scheduler": "*",
+        "csstype": "^3.0.2"
+      }
+    },
+    "@types/react-dom": {
+      "version": "17.0.13",
+      "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.13.tgz",
+      "integrity": "sha512-wEP+B8hzvy6ORDv1QBhcQia4j6ea4SFIBttHYpXKPFZRviBvknq0FRh3VrIxeXUmsPkwuXVZrVGG7KUVONmXCQ==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
+    "@types/react-is": {
+      "version": "17.0.3",
+      "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-17.0.3.tgz",
+      "integrity": "sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw==",
+      "requires": {
+        "@types/react": "*"
+      }
+    },
+    "@types/react-test-renderer": {
+      "version": "17.0.1",
+      "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-17.0.1.tgz",
+      "integrity": "sha512-3Fi2O6Zzq/f3QR9dRnlnHso9bMl7weKCviFmfF6B4LS1Uat6Hkm15k0ZAQuDz+UBq6B3+g+NM6IT2nr5QgPzCw==",
+      "dev": true,
+      "requires": {
+        "@types/react": "*"
+      }
+    },
+    "@types/react-transition-group": {
+      "version": "4.4.5",
+      "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz",
+      "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==",
+      "requires": {
+        "@types/react": "*"
+      }
+    },
+    "@types/scheduler": {
+      "version": "0.16.2",
+      "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
+      "integrity": "sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew=="
+    },
+    "@types/sinonjs__fake-timers": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.3.tgz",
+      "integrity": "sha512-E1dU4fzC9wN2QK2Cr1MLCfyHM8BoNnRFvuf45LYMPNDA+WqbNzC45S4UzPxvp1fFJ1rvSGU0bPvdd35VLmXG8g==",
+      "dev": true
+    },
+    "@types/sizzle": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/@types/sizzle/-/sizzle-2.3.3.tgz",
+      "integrity": "sha512-JYM8x9EGF163bEyhdJBpR2QX1R5naCJHC8ucJylJ3w9/CVBaskdQ8WqBf8MmQrd1kRvp/a4TS8HJ+bxzR7ZJYQ==",
+      "dev": true
+    },
+    "@types/stack-utils": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.1.tgz",
+      "integrity": "sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==",
+      "dev": true
+    },
+    "@types/testing-library__jest-dom": {
+      "version": "5.14.3",
+      "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.3.tgz",
+      "integrity": "sha512-oKZe+Mf4ioWlMuzVBaXQ9WDnEm1+umLx0InILg+yvZVBBDmzV5KfZyLrCvadtWcx8+916jLmHafcmqqffl+iIw==",
+      "dev": true,
+      "requires": {
+        "@types/jest": "*"
+      }
+    },
+    "@types/trusted-types": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.2.tgz",
+      "integrity": "sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg=="
+    },
+    "@types/yargs": {
+      "version": "16.0.4",
+      "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-16.0.4.tgz",
+      "integrity": "sha512-T8Yc9wt/5LbJyCaLiHPReJa0kApcIgJ7Bn735GjItUfh08Z1pJvu8QZqb9s+mMvKV6WUQRV7K2R46YbjMXTTJw==",
+      "dev": true,
+      "requires": {
+        "@types/yargs-parser": "*"
+      }
+    },
+    "@types/yargs-parser": {
+      "version": "21.0.0",
+      "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.0.tgz",
+      "integrity": "sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==",
+      "dev": true
+    },
+    "@types/yauzl": {
+      "version": "2.9.2",
+      "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
+      "integrity": "sha512-8uALY5LTvSuHgloDVUvWP3pIauILm+8/0pDMokuDYIoNsOkSwd5AiHBTSEJjKTDcZr5z8UpgOWZkxBF4iJftoA==",
+      "dev": true,
+      "optional": true,
+      "requires": {
+        "@types/node": "*"
+      }
+    },
+    "@typescript-eslint/eslint-plugin": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.16.0.tgz",
+      "integrity": "sha512-SJoba1edXvQRMmNI505Uo4XmGbxCK9ARQpkvOd00anxzri9RNQk0DDCxD+LIl+jYhkzOJiOMMKYEHnHEODjdCw==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/scope-manager": "5.16.0",
+        "@typescript-eslint/type-utils": "5.16.0",
+        "@typescript-eslint/utils": "5.16.0",
+        "debug": "^4.3.2",
+        "functional-red-black-tree": "^1.0.1",
+        "ignore": "^5.1.8",
+        "regexpp": "^3.2.0",
+        "semver": "^7.3.5",
+        "tsutils": "^3.21.0"
+      }
+    },
+    "@typescript-eslint/parser": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.16.0.tgz",
+      "integrity": "sha512-fkDq86F0zl8FicnJtdXakFs4lnuebH6ZADDw6CYQv0UZeIjHvmEw87m9/29nk2Dv5Lmdp0zQ3zDQhiMWQf/GbA==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/scope-manager": "5.16.0",
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/typescript-estree": "5.16.0",
+        "debug": "^4.3.2"
+      }
+    },
+    "@typescript-eslint/scope-manager": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.16.0.tgz",
+      "integrity": "sha512-P+Yab2Hovg8NekLIR/mOElCDPyGgFZKhGoZA901Yax6WR6HVeGLbsqJkZ+Cvk5nts/dAlFKm8PfL43UZnWdpIQ==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/visitor-keys": "5.16.0"
+      }
+    },
+    "@typescript-eslint/type-utils": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.16.0.tgz",
+      "integrity": "sha512-SKygICv54CCRl1Vq5ewwQUJV/8padIWvPgCxlWPGO/OgQLCijY9G7lDu6H+mqfQtbzDNlVjzVWQmeqbLMBLEwQ==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/utils": "5.16.0",
+        "debug": "^4.3.2",
+        "tsutils": "^3.21.0"
+      }
+    },
+    "@typescript-eslint/types": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.16.0.tgz",
+      "integrity": "sha512-oUorOwLj/3/3p/HFwrp6m/J2VfbLC8gjW5X3awpQJ/bSG+YRGFS4dpsvtQ8T2VNveV+LflQHjlLvB6v0R87z4g==",
+      "dev": true
+    },
+    "@typescript-eslint/typescript-estree": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.16.0.tgz",
+      "integrity": "sha512-SE4VfbLWUZl9MR+ngLSARptUv2E8brY0luCdgmUevU6arZRY/KxYoLI/3V/yxaURR8tLRN7bmZtJdgmzLHI6pQ==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/visitor-keys": "5.16.0",
+        "debug": "^4.3.2",
+        "globby": "^11.0.4",
+        "is-glob": "^4.0.3",
+        "semver": "^7.3.5",
+        "tsutils": "^3.21.0"
+      }
+    },
+    "@typescript-eslint/utils": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.16.0.tgz",
+      "integrity": "sha512-iYej2ER6AwmejLWMWzJIHy3nPJeGDuCqf8Jnb+jAQVoPpmWzwQOfa9hWVB8GIQE5gsCv/rfN4T+AYb/V06WseQ==",
+      "dev": true,
+      "requires": {
+        "@types/json-schema": "^7.0.9",
+        "@typescript-eslint/scope-manager": "5.16.0",
+        "@typescript-eslint/types": "5.16.0",
+        "@typescript-eslint/typescript-estree": "5.16.0",
+        "eslint-scope": "^5.1.1",
+        "eslint-utils": "^3.0.0"
+      }
+    },
+    "@typescript-eslint/visitor-keys": {
+      "version": "5.16.0",
+      "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.16.0.tgz",
+      "integrity": "sha512-jqxO8msp5vZDhikTwq9ubyMHqZ67UIvawohr4qF3KhlpL7gzSjOd+8471H3nh5LyABkaI85laEKKU8SnGUK5/g==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/types": "5.16.0",
+        "eslint-visitor-keys": "^3.0.0"
+      }
+    },
+    "@vaadin/router": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmjs.org/@vaadin/router/-/router-1.7.4.tgz",
+      "integrity": "sha512-B4JVtzFVUMlsjuJHNXEMfNZrM4QDrdeOMc6EEigiHYxwF82py6yDdP6SWP0aPoP3f6aQHt51tLWdXSpkKpWf7A==",
+      "requires": {
+        "@vaadin/vaadin-usage-statistics": "^2.1.0",
+        "path-to-regexp": "2.4.0"
+      }
+    },
+    "@vaadin/vaadin-development-mode-detector": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/@vaadin/vaadin-development-mode-detector/-/vaadin-development-mode-detector-2.0.4.tgz",
+      "integrity": "sha512-S+PaFrZpK8uBIOnIHxjntTrgumd5ztuCnZww96ydGKXgo9whXfZsbMwDuD/102a/IuPUMyF+dh/n3PbWzJ6igA=="
+    },
+    "@vaadin/vaadin-usage-statistics": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/@vaadin/vaadin-usage-statistics/-/vaadin-usage-statistics-2.1.0.tgz",
+      "integrity": "sha512-e81nbqY5zsaYhLJuOVkJkB/Um1pGK5POIqIlTNhUfjeoyGaJ63tiX8+D5n6F+GgVxUTLUarsKa6SKRcQel0AzA==",
+      "requires": {
+        "@vaadin/vaadin-development-mode-detector": "^2.0.0"
+      }
+    },
+    "@webassemblyjs/ast": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.1.tgz",
+      "integrity": "sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/helper-numbers": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1"
+      }
+    },
+    "@webassemblyjs/floating-point-hex-parser": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz",
+      "integrity": "sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ==",
+      "dev": true,
+      "peer": true
+    },
+    "@webassemblyjs/helper-api-error": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz",
+      "integrity": "sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg==",
+      "dev": true,
+      "peer": true
+    },
+    "@webassemblyjs/helper-buffer": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz",
+      "integrity": "sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA==",
+      "dev": true,
+      "peer": true
+    },
+    "@webassemblyjs/helper-numbers": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz",
+      "integrity": "sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/floating-point-hex-parser": "1.11.1",
+        "@webassemblyjs/helper-api-error": "1.11.1",
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "@webassemblyjs/helper-wasm-bytecode": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz",
+      "integrity": "sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q==",
+      "dev": true,
+      "peer": true
+    },
+    "@webassemblyjs/helper-wasm-section": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz",
+      "integrity": "sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-buffer": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/wasm-gen": "1.11.1"
+      }
+    },
+    "@webassemblyjs/ieee754": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz",
+      "integrity": "sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@xtuc/ieee754": "^1.2.0"
+      }
+    },
+    "@webassemblyjs/leb128": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.1.tgz",
+      "integrity": "sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "@webassemblyjs/utf8": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.1.tgz",
+      "integrity": "sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ==",
+      "dev": true,
+      "peer": true
+    },
+    "@webassemblyjs/wasm-edit": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz",
+      "integrity": "sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-buffer": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/helper-wasm-section": "1.11.1",
+        "@webassemblyjs/wasm-gen": "1.11.1",
+        "@webassemblyjs/wasm-opt": "1.11.1",
+        "@webassemblyjs/wasm-parser": "1.11.1",
+        "@webassemblyjs/wast-printer": "1.11.1"
+      }
+    },
+    "@webassemblyjs/wasm-gen": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz",
+      "integrity": "sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/ieee754": "1.11.1",
+        "@webassemblyjs/leb128": "1.11.1",
+        "@webassemblyjs/utf8": "1.11.1"
+      }
+    },
+    "@webassemblyjs/wasm-opt": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz",
+      "integrity": "sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-buffer": "1.11.1",
+        "@webassemblyjs/wasm-gen": "1.11.1",
+        "@webassemblyjs/wasm-parser": "1.11.1"
+      }
+    },
+    "@webassemblyjs/wasm-parser": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz",
+      "integrity": "sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/helper-api-error": "1.11.1",
+        "@webassemblyjs/helper-wasm-bytecode": "1.11.1",
+        "@webassemblyjs/ieee754": "1.11.1",
+        "@webassemblyjs/leb128": "1.11.1",
+        "@webassemblyjs/utf8": "1.11.1"
+      }
+    },
+    "@webassemblyjs/wast-printer": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz",
+      "integrity": "sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@webassemblyjs/ast": "1.11.1",
+        "@xtuc/long": "4.2.2"
+      }
+    },
+    "@xobotyi/scrollbar-width": {
+      "version": "1.9.5",
+      "resolved": "https://registry.npmjs.org/@xobotyi/scrollbar-width/-/scrollbar-width-1.9.5.tgz",
+      "integrity": "sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ=="
+    },
+    "@xtuc/ieee754": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz",
+      "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==",
+      "dev": true,
+      "peer": true
+    },
+    "@xtuc/long": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz",
+      "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
+      "dev": true,
+      "peer": true
+    },
+    "abab": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.5.tgz",
+      "integrity": "sha512-9IK9EadsbHo6jLWIpxpR6pL0sazTXV6+SQv25ZB+F7Bj9mJNaOc4nCRabwd5M/JwmUa8idz6Eci6eKfJryPs6Q==",
+      "dev": true
+    },
+    "acorn": {
+      "version": "8.7.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.7.0.tgz",
+      "integrity": "sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==",
+      "dev": true
+    },
+    "acorn-globals": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz",
+      "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==",
+      "dev": true,
+      "requires": {
+        "acorn": "^7.1.1",
+        "acorn-walk": "^7.1.1"
+      },
+      "dependencies": {
+        "acorn": {
+          "version": "7.4.1",
+          "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz",
+          "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==",
+          "dev": true
+        }
+      }
+    },
+    "acorn-import-assertions": {
+      "version": "1.7.6",
+      "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.7.6.tgz",
+      "integrity": "sha512-FlVvVFA1TX6l3lp8VjDnYYq7R1nyW6x3svAt4nDgrWQ9SBaSh9CnbwgSUTasgfNfOG5HlM1ehugCvM+hjo56LA==",
+      "dev": true,
+      "peer": true,
+      "requires": {}
+    },
+    "acorn-jsx": {
+      "version": "5.3.2",
+      "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+      "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+      "dev": true,
+      "requires": {}
+    },
+    "acorn-walk": {
+      "version": "7.2.0",
+      "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz",
+      "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==",
+      "dev": true
+    },
+    "agent-base": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+      "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+      "dev": true,
+      "requires": {
+        "debug": "4"
+      }
+    },
+    "aggregate-error": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
+      "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==",
+      "dev": true,
+      "requires": {
+        "clean-stack": "^2.0.0",
+        "indent-string": "^4.0.0"
+      }
+    },
+    "ajv": {
+      "version": "6.12.6",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
+      "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
+      "dev": true,
+      "requires": {
+        "fast-deep-equal": "^3.1.1",
+        "fast-json-stable-stringify": "^2.0.0",
+        "json-schema-traverse": "^0.4.1",
+        "uri-js": "^4.2.2"
+      }
+    },
+    "ajv-keywords": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
+      "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {}
+    },
+    "ansi-colors": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.1.tgz",
+      "integrity": "sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==",
+      "dev": true
+    },
+    "ansi-escapes": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz",
+      "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
+      "dev": true,
+      "requires": {
+        "type-fest": "^0.21.3"
+      }
+    },
+    "ansi-regex": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+      "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+      "dev": true
+    },
+    "ansi-styles": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+      "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+      "dev": true,
+      "requires": {
+        "color-convert": "^2.0.1"
+      }
+    },
+    "anymatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.2.tgz",
+      "integrity": "sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg==",
+      "requires": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      }
+    },
+    "arch": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/arch/-/arch-2.2.0.tgz",
+      "integrity": "sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==",
+      "dev": true
+    },
+    "arg": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
+      "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "argparse": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "dev": true
+    },
+    "aria-query": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.0.0.tgz",
+      "integrity": "sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg==",
+      "dev": true
+    },
+    "array-includes": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.4.tgz",
+      "integrity": "sha512-ZTNSQkmWumEbiHO2GF4GmWxYVTiQyJy2XOTa15sdQSrvKn7l+180egQMqlrMOUMCyLMD7pmyQe4mMDUT6Behrw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1",
+        "get-intrinsic": "^1.1.1",
+        "is-string": "^1.0.7"
+      }
+    },
+    "array-union": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
+      "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==",
+      "dev": true
+    },
+    "array.prototype.flat": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.2.5.tgz",
+      "integrity": "sha512-KaYU+S+ndVqyUnignHftkwc58o3uVU1jzczILJ1tN2YaIZpFIKBiP/x/j97E5MVPsaCloPbqWLB/8qCTVvT2qg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.0"
+      }
+    },
+    "array.prototype.flatmap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.2.5.tgz",
+      "integrity": "sha512-08u6rVyi1Lj7oqWbS9nUxliETrtIROT4XGTA4D/LWGten6E3ocm7cy9SIrmNHOL5XVbVuckUp3X6Xyg8/zpvHA==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.0"
+      }
+    },
+    "arrify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz",
+      "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
+      "dev": true,
+      "peer": true
+    },
+    "asn1": {
+      "version": "0.2.4",
+      "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz",
+      "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==",
+      "dev": true,
+      "requires": {
+        "safer-buffer": "~2.1.0"
+      }
+    },
+    "assert-plus": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
+      "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=",
+      "dev": true
+    },
+    "ast-types-flow": {
+      "version": "0.0.7",
+      "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.7.tgz",
+      "integrity": "sha1-9wtzXGvKGlycItmCw+Oef+ujva0=",
+      "dev": true
+    },
+    "astral-regex": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz",
+      "integrity": "sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==",
+      "dev": true
+    },
+    "async": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz",
+      "integrity": "sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==",
+      "dev": true
+    },
+    "asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
+      "dev": true
+    },
+    "at-least-node": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
+      "integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==",
+      "dev": true
+    },
+    "atob": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz",
+      "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==",
+      "dev": true
+    },
+    "aws-sign2": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
+      "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=",
+      "dev": true
+    },
+    "aws4": {
+      "version": "1.11.0",
+      "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.11.0.tgz",
+      "integrity": "sha512-xh1Rl34h6Fi1DC2WWKfxUTVqRsNnr6LsKz2+hfwDxQJWmrx8+c7ylaqBMcHfl1U1r2dsifOvKX3LQuLNZ+XSvA==",
+      "dev": true
+    },
+    "axe-core": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.1.tgz",
+      "integrity": "sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw==",
+      "dev": true
+    },
+    "axobject-query": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-2.2.0.tgz",
+      "integrity": "sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==",
+      "dev": true
+    },
+    "babel-jest": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-27.5.1.tgz",
+      "integrity": "sha512-cdQ5dXjGRd0IBRATiQ4mZGlGlRE8kJpjPOixdNRdT+m3UcNqmYWN6rK6nvtXYfY3D76cb8s/O1Ss8ea24PIwcg==",
+      "dev": true,
+      "requires": {
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/babel__core": "^7.1.14",
+        "babel-plugin-istanbul": "^6.1.1",
+        "babel-preset-jest": "^27.5.1",
+        "chalk": "^4.0.0",
+        "graceful-fs": "^4.2.9",
+        "slash": "^3.0.0"
+      }
+    },
+    "babel-plugin-istanbul": {
+      "version": "6.1.1",
+      "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz",
+      "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==",
+      "dev": true,
+      "requires": {
+        "@babel/helper-plugin-utils": "^7.0.0",
+        "@istanbuljs/load-nyc-config": "^1.0.0",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-instrument": "^5.0.4",
+        "test-exclude": "^6.0.0"
+      }
+    },
+    "babel-plugin-jest-hoist": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-27.5.1.tgz",
+      "integrity": "sha512-50wCwD5EMNW4aRpOwtqzyZHIewTYNxLA4nhB+09d8BIssfNfzBRhkBIHiaPv1Si226TQSvp8gxAJm2iY2qs2hQ==",
+      "dev": true,
+      "requires": {
+        "@babel/template": "^7.3.3",
+        "@babel/types": "^7.3.3",
+        "@types/babel__core": "^7.0.0",
+        "@types/babel__traverse": "^7.0.6"
+      }
+    },
+    "babel-plugin-macros": {
+      "version": "2.8.0",
+      "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-2.8.0.tgz",
+      "integrity": "sha512-SEP5kJpfGYqYKpBrj5XU3ahw5p5GOHJ0U5ssOSQ/WBVdwkD2Dzlce95exQTs3jOVWPPKLBN2rlEWkCK7dSmLvg==",
+      "requires": {
+        "@babel/runtime": "^7.7.2",
+        "cosmiconfig": "^6.0.0",
+        "resolve": "^1.12.0"
+      }
+    },
+    "babel-preset-current-node-syntax": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz",
+      "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==",
+      "dev": true,
+      "requires": {
+        "@babel/plugin-syntax-async-generators": "^7.8.4",
+        "@babel/plugin-syntax-bigint": "^7.8.3",
+        "@babel/plugin-syntax-class-properties": "^7.8.3",
+        "@babel/plugin-syntax-import-meta": "^7.8.3",
+        "@babel/plugin-syntax-json-strings": "^7.8.3",
+        "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3",
+        "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3",
+        "@babel/plugin-syntax-numeric-separator": "^7.8.3",
+        "@babel/plugin-syntax-object-rest-spread": "^7.8.3",
+        "@babel/plugin-syntax-optional-catch-binding": "^7.8.3",
+        "@babel/plugin-syntax-optional-chaining": "^7.8.3",
+        "@babel/plugin-syntax-top-level-await": "^7.8.3"
+      }
+    },
+    "babel-preset-jest": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-27.5.1.tgz",
+      "integrity": "sha512-Nptf2FzlPCWYuJg41HBqXVT8ym6bXOevuCTbhxlUpjwtysGaIWFvDEjp4y+G7fl13FgOdjs7P/DmErqH7da0Ag==",
+      "dev": true,
+      "requires": {
+        "babel-plugin-jest-hoist": "^27.5.1",
+        "babel-preset-current-node-syntax": "^1.0.0"
+      }
+    },
+    "balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
+    },
+    "bcrypt-pbkdf": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
+      "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=",
+      "dev": true,
+      "requires": {
+        "tweetnacl": "^0.14.3"
+      }
+    },
+    "big-integer": {
+      "version": "1.6.51",
+      "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
+      "integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg=="
+    },
+    "binary-extensions": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",
+      "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA=="
+    },
+    "blob-util": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz",
+      "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==",
+      "dev": true
+    },
+    "blocking-elements": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/blocking-elements/-/blocking-elements-0.1.1.tgz",
+      "integrity": "sha512-/SLWbEzMoVIMZACCyhD/4Ya2M1PWP1qMKuiymowPcI+PdWDARqeARBjhj73kbUBCxEmTZCUu5TAqxtwUO9C1Ig=="
+    },
+    "bluebird": {
+      "version": "3.7.2",
+      "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
+      "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
+      "dev": true
+    },
+    "brace-expansion": {
+      "version": "1.1.11",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
+      "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+      "requires": {
+        "balanced-match": "^1.0.0",
+        "concat-map": "0.0.1"
+      }
+    },
+    "braces": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
+      "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
+      "requires": {
+        "fill-range": "^7.0.1"
+      }
+    },
+    "broadcast-channel": {
+      "version": "3.7.0",
+      "resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
+      "integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
+      "requires": {
+        "@babel/runtime": "^7.7.2",
+        "detect-node": "^2.1.0",
+        "js-sha3": "0.8.0",
+        "microseconds": "0.2.0",
+        "nano-time": "1.0.0",
+        "oblivious-set": "1.0.0",
+        "rimraf": "3.0.2",
+        "unload": "2.2.0"
+      }
+    },
+    "browser-process-hrtime": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz",
+      "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==",
+      "dev": true
+    },
+    "browserslist": {
+      "version": "4.19.1",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.19.1.tgz",
+      "integrity": "sha512-u2tbbG5PdKRTUoctO3NBD8FQ5HdPh1ZXPHzp1rwaa5jTc+RV9/+RlWiAIKmjRPQF+xbGM9Kklj5bZQFa2s/38A==",
+      "requires": {
+        "caniuse-lite": "^1.0.30001286",
+        "electron-to-chromium": "^1.4.17",
+        "escalade": "^3.1.1",
+        "node-releases": "^2.0.1",
+        "picocolors": "^1.0.0"
+      }
+    },
+    "bs-logger": {
+      "version": "0.2.6",
+      "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz",
+      "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==",
+      "dev": true,
+      "requires": {
+        "fast-json-stable-stringify": "2.x"
+      }
+    },
+    "bser": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz",
+      "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==",
+      "dev": true,
+      "requires": {
+        "node-int64": "^0.4.0"
+      }
+    },
+    "buffer-crc32": {
+      "version": "0.2.13",
+      "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+      "integrity": "sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=",
+      "dev": true
+    },
+    "buffer-from": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
+      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
+      "dev": true
+    },
+    "cachedir": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/cachedir/-/cachedir-2.3.0.tgz",
+      "integrity": "sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==",
+      "dev": true
+    },
+    "call-bind": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz",
+      "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.0.2"
+      }
+    },
+    "callsites": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+      "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="
+    },
+    "camelcase": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.2.1.tgz",
+      "integrity": "sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA==",
+      "dev": true
+    },
+    "camelcase-keys": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-6.2.2.tgz",
+      "integrity": "sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "camelcase": "^5.3.1",
+        "map-obj": "^4.0.0",
+        "quick-lru": "^4.0.1"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "5.3.1",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
+          "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
+          "dev": true,
+          "peer": true
+        }
+      }
+    },
+    "caniuse-lite": {
+      "version": "1.0.30001294",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001294.tgz",
+      "integrity": "sha512-LiMlrs1nSKZ8qkNhpUf5KD0Al1KCBE3zaT7OLOwEkagXMEDij98SiOovn9wxVGQpklk9vVC/pUSqgYmkmKOS8g=="
+    },
+    "caseless": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
+      "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=",
+      "dev": true
+    },
+    "chalk": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+      "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.1.0",
+        "supports-color": "^7.1.0"
+      },
+      "dependencies": {
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "char-regex": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz",
+      "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==",
+      "dev": true
+    },
+    "chart.js": {
+      "version": "3.7.1",
+      "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.7.1.tgz",
+      "integrity": "sha512-8knRegQLFnPQAheZV8MjxIXc5gQEfDFD897BJgv/klO/vtIyFFmgMXrNfgrXpbTr/XbTturxRgxIXx/Y+ASJBA==",
+      "peer": true
+    },
+    "check-more-types": {
+      "version": "2.24.0",
+      "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz",
+      "integrity": "sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=",
+      "dev": true
+    },
+    "chokidar": {
+      "version": "3.5.2",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz",
+      "integrity": "sha512-ekGhOnNVPgT77r4K/U3GDhu+FQ2S8TnK/s2KbIGXi0SZWuwkZ2QNyfWdZW+TVfn84DpEP7rLeCt2UI6bJ8GwbQ==",
+      "requires": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "fsevents": "~2.3.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      }
+    },
+    "chrome-trace-event": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz",
+      "integrity": "sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==",
+      "dev": true,
+      "peer": true
+    },
+    "ci-info": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.2.0.tgz",
+      "integrity": "sha512-dVqRX7fLUm8J6FgHJ418XuIgDLZDkYcDFTeL6TA2gt5WlIZUQrrH6EZrNClwT/H0FateUsZkGIOPRrLbP+PR9A==",
+      "dev": true
+    },
+    "cjs-module-lexer": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz",
+      "integrity": "sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==",
+      "dev": true
+    },
+    "clean-stack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz",
+      "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==",
+      "dev": true
+    },
+    "cli-cursor": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz",
+      "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
+      "dev": true,
+      "requires": {
+        "restore-cursor": "^3.1.0"
+      }
+    },
+    "cli-table3": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.0.tgz",
+      "integrity": "sha512-gnB85c3MGC7Nm9I/FkiasNBOKjOiO1RNuXXarQms37q4QMpWdlbBgD/VnOStA2faG1dpXMv31RFApjX1/QdgWQ==",
+      "dev": true,
+      "requires": {
+        "colors": "^1.1.2",
+        "object-assign": "^4.1.0",
+        "string-width": "^4.2.0"
+      }
+    },
+    "cli-truncate": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-2.1.0.tgz",
+      "integrity": "sha512-n8fOixwDD6b/ObinzTrp1ZKFzbgvKZvuz/TvejnLn1aQfC6r52XEx85FmuC+3HI+JM7coBRXUvNqEU2PHVrHpg==",
+      "dev": true,
+      "requires": {
+        "slice-ansi": "^3.0.0",
+        "string-width": "^4.2.0"
+      }
+    },
+    "cliui": {
+      "version": "7.0.4",
+      "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+      "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+      "dev": true,
+      "requires": {
+        "string-width": "^4.2.0",
+        "strip-ansi": "^6.0.0",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "clone-regexp": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/clone-regexp/-/clone-regexp-2.2.0.tgz",
+      "integrity": "sha512-beMpP7BOtTipFuW8hrJvREQ2DrRu3BE7by0ZpibtfBA+qfHYvMGTc2Yb1JMYPKg/JUw0CHYvpg796aNTSW9z7Q==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "is-regexp": "^2.0.0"
+      }
+    },
+    "clsx": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
+      "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg=="
+    },
+    "co": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
+      "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
+      "dev": true
+    },
+    "collect-v8-coverage": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz",
+      "integrity": "sha512-iBPtljfCNcTKNAto0KEtDfZ3qzjJvqE3aTGZsbhjSBlorqpXJlaWWtPO35D+ZImoC3KWejX64o+yPGxhWSTzfg==",
+      "dev": true
+    },
+    "color-convert": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+      "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+      "dev": true,
+      "requires": {
+        "color-name": "~1.1.4"
+      }
+    },
+    "color-name": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+      "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+      "dev": true
+    },
+    "colord": {
+      "version": "2.9.2",
+      "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.2.tgz",
+      "integrity": "sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ==",
+      "dev": true,
+      "peer": true
+    },
+    "colorette": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
+      "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==",
+      "dev": true
+    },
+    "colors": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
+      "integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA==",
+      "dev": true,
+      "optional": true
+    },
+    "combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "requires": {
+        "delayed-stream": "~1.0.0"
+      }
+    },
+    "commander": {
+      "version": "2.20.3",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+      "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+      "dev": true,
+      "peer": true
+    },
+    "common-tags": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/common-tags/-/common-tags-1.8.0.tgz",
+      "integrity": "sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==",
+      "dev": true
+    },
+    "concat-map": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+      "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
+    },
+    "convert-source-map": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.8.0.tgz",
+      "integrity": "sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA==",
+      "requires": {
+        "safe-buffer": "~5.1.1"
+      },
+      "dependencies": {
+        "safe-buffer": {
+          "version": "5.1.2",
+          "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+          "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
+        }
+      }
+    },
+    "copy-to-clipboard": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.1.tgz",
+      "integrity": "sha512-i13qo6kIHTTpCm8/Wup+0b1mVWETvu2kIMzKoK8FpkLkFxlt0znUAHcMzox+T8sPlqtZXq3CulEjQHsYiGFJUw==",
+      "requires": {
+        "toggle-selection": "^1.0.6"
+      }
+    },
+    "core-js": {
+      "version": "3.21.1",
+      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.21.1.tgz",
+      "integrity": "sha512-FRq5b/VMrWlrmCzwRrpDYNxyHP9BcAZC+xHJaqTgIE5091ZV1NTmyh0sGOg5XqpnHvR0svdy0sv1gWA1zmhxig==",
+      "dev": true
+    },
+    "core-js-pure": {
+      "version": "3.22.2",
+      "resolved": "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.22.2.tgz",
+      "integrity": "sha512-Lb+/XT4WC4PaCWWtZpNPaXmjiNDUe5CJuUtbkMrIM1kb1T/jJoAIp+bkVP/r5lHzMr+ZAAF8XHp7+my6Ol0ysQ==",
+      "dev": true
+    },
+    "core-util-is": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
+      "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
+      "dev": true
+    },
+    "cosmiconfig": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-6.0.0.tgz",
+      "integrity": "sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg==",
+      "requires": {
+        "@types/parse-json": "^4.0.0",
+        "import-fresh": "^3.1.0",
+        "parse-json": "^5.0.0",
+        "path-type": "^4.0.0",
+        "yaml": "^1.7.2"
+      }
+    },
+    "create-require": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
+      "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "cross-spawn": {
+      "version": "7.0.3",
+      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
+      "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.1.0",
+        "shebang-command": "^2.0.0",
+        "which": "^2.0.1"
+      }
+    },
+    "css": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/css/-/css-3.0.0.tgz",
+      "integrity": "sha512-DG9pFfwOrzc+hawpmqX/dHYHJG+Bsdb0klhyi1sDneOgGOXy9wQIC8hzyVp1e4NRYDBdxcylvywPkkXCHAzTyQ==",
+      "dev": true,
+      "requires": {
+        "inherits": "^2.0.4",
+        "source-map": "^0.6.1",
+        "source-map-resolve": "^0.6.0"
+      }
+    },
+    "css-functions-list": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.0.1.tgz",
+      "integrity": "sha512-PriDuifDt4u4rkDgnqRCLnjfMatufLmWNfQnGCq34xZwpY3oabwhB9SqRBmuvWUgndbemCFlKqg+nO7C2q0SBw==",
+      "dev": true,
+      "peer": true
+    },
+    "css-in-js-utils": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/css-in-js-utils/-/css-in-js-utils-2.0.1.tgz",
+      "integrity": "sha512-PJF0SpJT+WdbVVt0AOYp9C8GnuruRlL/UFW7932nLWmFLQTaWEzTBQEx7/hn4BuV+WON75iAViSUJLiU3PKbpA==",
+      "requires": {
+        "hyphenate-style-name": "^1.0.2",
+        "isobject": "^3.0.1"
+      }
+    },
+    "css-loader": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-6.2.0.tgz",
+      "integrity": "sha512-/rvHfYRjIpymZblf49w8jYcRo2y9gj6rV8UroHGmBxKrIyGLokpycyKzp9OkitvqT29ZSpzJ0Ic7SpnJX3sC8g==",
+      "dev": true,
+      "requires": {
+        "icss-utils": "^5.1.0",
+        "postcss": "^8.2.15",
+        "postcss-modules-extract-imports": "^3.0.0",
+        "postcss-modules-local-by-default": "^4.0.0",
+        "postcss-modules-scope": "^3.0.0",
+        "postcss-modules-values": "^4.0.0",
+        "postcss-value-parser": "^4.1.0",
+        "semver": "^7.3.5"
+      }
+    },
+    "css-tree": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz",
+      "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==",
+      "requires": {
+        "mdn-data": "2.0.14",
+        "source-map": "^0.6.1"
+      }
+    },
+    "css.escape": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+      "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
+      "dev": true
+    },
+    "cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true
+    },
+    "cssom": {
+      "version": "0.4.4",
+      "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz",
+      "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==",
+      "dev": true
+    },
+    "cssstyle": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-2.3.0.tgz",
+      "integrity": "sha512-AZL67abkUzIuvcHqk7c09cezpGNcxUxU4Ioi/05xHk4DQeTkWmGYftIE6ctU6AEt+Gn4n1lDStOtj7FKycP71A==",
+      "dev": true,
+      "requires": {
+        "cssom": "~0.3.6"
+      },
+      "dependencies": {
+        "cssom": {
+          "version": "0.3.8",
+          "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz",
+          "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==",
+          "dev": true
+        }
+      }
+    },
+    "csstype": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.0.tgz",
+      "integrity": "sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA=="
+    },
+    "cypress": {
+      "version": "8.3.1",
+      "resolved": "https://registry.npmjs.org/cypress/-/cypress-8.3.1.tgz",
+      "integrity": "sha512-1v6pfx+/5cXhaT5T6QKOvnkawmEHWHLiVzm3MYMoQN1fkX2Ma1C32STd3jBStE9qT5qPSTILjGzypVRxCBi40g==",
+      "dev": true,
+      "requires": {
+        "@cypress/request": "^2.88.6",
+        "@cypress/xvfb": "^1.2.4",
+        "@types/node": "^14.14.31",
+        "@types/sinonjs__fake-timers": "^6.0.2",
+        "@types/sizzle": "^2.3.2",
+        "arch": "^2.2.0",
+        "blob-util": "^2.0.2",
+        "bluebird": "^3.7.2",
+        "cachedir": "^2.3.0",
+        "chalk": "^4.1.0",
+        "check-more-types": "^2.24.0",
+        "cli-cursor": "^3.1.0",
+        "cli-table3": "~0.6.0",
+        "commander": "^5.1.0",
+        "common-tags": "^1.8.0",
+        "dayjs": "^1.10.4",
+        "debug": "^4.3.2",
+        "enquirer": "^2.3.6",
+        "eventemitter2": "^6.4.3",
+        "execa": "4.1.0",
+        "executable": "^4.1.1",
+        "extract-zip": "2.0.1",
+        "figures": "^3.2.0",
+        "fs-extra": "^9.1.0",
+        "getos": "^3.2.1",
+        "is-ci": "^3.0.0",
+        "is-installed-globally": "~0.4.0",
+        "lazy-ass": "^1.6.0",
+        "listr2": "^3.8.3",
+        "lodash": "^4.17.21",
+        "log-symbols": "^4.0.0",
+        "minimist": "^1.2.5",
+        "ospath": "^1.2.2",
+        "pretty-bytes": "^5.6.0",
+        "ramda": "~0.27.1",
+        "request-progress": "^3.0.0",
+        "supports-color": "^8.1.1",
+        "tmp": "~0.2.1",
+        "untildify": "^4.0.0",
+        "url": "^0.11.0",
+        "yauzl": "^2.10.0"
+      },
+      "dependencies": {
+        "@types/node": {
+          "version": "14.17.15",
+          "resolved": "https://registry.npmjs.org/@types/node/-/node-14.17.15.tgz",
+          "integrity": "sha512-D1sdW0EcSCmNdLKBGMYb38YsHUS6JcM7yQ6sLQ9KuZ35ck7LYCKE7kYFHOO59ayFOY3zobWVZxf4KXhYHcHYFA==",
+          "dev": true
+        },
+        "commander": {
+          "version": "5.1.0",
+          "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz",
+          "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==",
+          "dev": true
+        },
+        "execa": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz",
+          "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==",
+          "dev": true,
+          "requires": {
+            "cross-spawn": "^7.0.0",
+            "get-stream": "^5.0.0",
+            "human-signals": "^1.1.1",
+            "is-stream": "^2.0.0",
+            "merge-stream": "^2.0.0",
+            "npm-run-path": "^4.0.0",
+            "onetime": "^5.1.0",
+            "signal-exit": "^3.0.2",
+            "strip-final-newline": "^2.0.0"
+          }
+        },
+        "get-stream": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+          "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+          "dev": true,
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        },
+        "human-signals": {
+          "version": "1.1.1",
+          "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz",
+          "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==",
+          "dev": true
+        }
+      }
+    },
+    "damerau-levenshtein": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
+      "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==",
+      "dev": true
+    },
+    "dashdash": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz",
+      "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=",
+      "dev": true,
+      "requires": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "data-uri-to-buffer": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz",
+      "integrity": "sha512-Vr3mLBA8qWmcuschSLAOogKgQ/Jwxulv3RNE4FXnYWRGujzrRWQI4m12fQqRkwX06C0KanhLr4hK+GydchZsaA==",
+      "dev": true
+    },
+    "data-urls": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz",
+      "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==",
+      "dev": true,
+      "requires": {
+        "abab": "^2.0.3",
+        "whatwg-mimetype": "^2.3.0",
+        "whatwg-url": "^8.0.0"
+      }
+    },
+    "dayjs": {
+      "version": "1.10.8",
+      "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.10.8.tgz",
+      "integrity": "sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow=="
+    },
+    "debug": {
+      "version": "4.3.4",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
+      "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+      "requires": {
+        "ms": "2.1.2"
+      },
+      "dependencies": {
+        "ms": {
+          "version": "2.1.2",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
+          "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+        }
+      }
+    },
+    "decamelize": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+      "dev": true,
+      "peer": true
+    },
+    "decamelize-keys": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/decamelize-keys/-/decamelize-keys-1.1.0.tgz",
+      "integrity": "sha1-0XGoeTMlKAfrPLYdwcFEXQeN8tk=",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "decamelize": "^1.1.0",
+        "map-obj": "^1.0.0"
+      },
+      "dependencies": {
+        "map-obj": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
+          "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=",
+          "dev": true,
+          "peer": true
+        }
+      }
+    },
+    "decimal.js": {
+      "version": "10.3.1",
+      "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.3.1.tgz",
+      "integrity": "sha512-V0pfhfr8suzyPGOx3nmq4aHqabehUZn6Ch9kyFpV79TGDTWFmHqUqXdabR7QHqxzrYolF4+tVmJhUG4OURg5dQ==",
+      "dev": true
+    },
+    "decode-uri-component": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
+      "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=",
+      "dev": true
+    },
+    "dedent": {
+      "version": "0.7.0",
+      "resolved": "https://registry.npmjs.org/dedent/-/dedent-0.7.0.tgz",
+      "integrity": "sha1-JJXduvbrh0q7Dhvp3yLS5aVEMmw=",
+      "dev": true
+    },
+    "deep-is": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+      "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+      "dev": true
+    },
+    "deepmerge": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.2.2.tgz",
+      "integrity": "sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==",
+      "dev": true
+    },
+    "define-properties": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
+      "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
+      "dev": true,
+      "requires": {
+        "object-keys": "^1.0.12"
+      }
+    },
+    "delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
+      "dev": true
+    },
+    "detect-newline": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
+      "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
+      "dev": true
+    },
+    "detect-node": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
+      "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g=="
+    },
+    "diff": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
+      "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "diff-sequences": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-27.5.1.tgz",
+      "integrity": "sha512-k1gCAXAsNgLwEL+Y8Wvl+M6oEFj5bgazfZULpS5CneoPPXRaCCW7dm+q21Ky2VEE5X+VeRDBVg1Pcvvsr4TtNQ==",
+      "dev": true
+    },
+    "dir-glob": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
+      "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==",
+      "dev": true,
+      "requires": {
+        "path-type": "^4.0.0"
+      }
+    },
+    "doctrine": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz",
+      "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==",
+      "dev": true,
+      "requires": {
+        "esutils": "^2.0.2"
+      }
+    },
+    "dom-accessibility-api": {
+      "version": "0.5.13",
+      "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.13.tgz",
+      "integrity": "sha512-R305kwb5CcMDIpSHUnLyIAp7SrSPBx6F0VfQFB3M75xVMHhXJJIdePYgbPPh1o57vCHNu5QztokWUPsLjWzFqw==",
+      "dev": true
+    },
+    "dom-helpers": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
+      "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
+      "requires": {
+        "@babel/runtime": "^7.8.7",
+        "csstype": "^3.0.2"
+      }
+    },
+    "domexception": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz",
+      "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==",
+      "dev": true,
+      "requires": {
+        "webidl-conversions": "^5.0.0"
+      },
+      "dependencies": {
+        "webidl-conversions": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz",
+          "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==",
+          "dev": true
+        }
+      }
+    },
+    "ecc-jsbn": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz",
+      "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=",
+      "dev": true,
+      "requires": {
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.1.0"
+      }
+    },
+    "electron-to-chromium": {
+      "version": "1.4.29",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.29.tgz",
+      "integrity": "sha512-N2Jbwxo5Rum8G2YXeUxycs1sv4Qme/ry71HG73bv8BvZl+I/4JtRgK/En+ST/Wh/yF1fqvVCY4jZBgMxnhjtBA=="
+    },
+    "emittery": {
+      "version": "0.8.1",
+      "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.8.1.tgz",
+      "integrity": "sha512-uDfvUjVrfGJJhymx/kz6prltenw1u7WrCg1oa94zYY8xxVpLLUu045LAT0dhDZdXG58/EpPL/5kA180fQ/qudg==",
+      "dev": true
+    },
+    "emoji-regex": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+      "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+      "dev": true
+    },
+    "end-of-stream": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
+      "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
+      "dev": true,
+      "requires": {
+        "once": "^1.4.0"
+      }
+    },
+    "enhanced-resolve": {
+      "version": "5.8.2",
+      "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.8.2.tgz",
+      "integrity": "sha512-F27oB3WuHDzvR2DOGNTaYy0D5o0cnrv8TeI482VM4kYgQd/FT9lUQwuNsJ0oOHtBUq7eiW5ytqzp7nBFknL+GA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "graceful-fs": "^4.2.4",
+        "tapable": "^2.2.0"
+      }
+    },
+    "enquirer": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.3.6.tgz",
+      "integrity": "sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==",
+      "dev": true,
+      "requires": {
+        "ansi-colors": "^4.1.1"
+      }
+    },
+    "error-ex": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
+      "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
+      "requires": {
+        "is-arrayish": "^0.2.1"
+      }
+    },
+    "error-stack-parser": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.0.7.tgz",
+      "integrity": "sha512-chLOW0ZGRf4s8raLrDxa5sdkvPec5YdvwbFnqJme4rk0rFajP8mPtrDL1+I+CwrQDCjswDA5sREX7jYQDQs9vA==",
+      "requires": {
+        "stackframe": "^1.1.1"
+      }
+    },
+    "es-abstract": {
+      "version": "1.19.1",
+      "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.19.1.tgz",
+      "integrity": "sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "es-to-primitive": "^1.2.1",
+        "function-bind": "^1.1.1",
+        "get-intrinsic": "^1.1.1",
+        "get-symbol-description": "^1.0.0",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.2",
+        "internal-slot": "^1.0.3",
+        "is-callable": "^1.2.4",
+        "is-negative-zero": "^2.0.1",
+        "is-regex": "^1.1.4",
+        "is-shared-array-buffer": "^1.0.1",
+        "is-string": "^1.0.7",
+        "is-weakref": "^1.0.1",
+        "object-inspect": "^1.11.0",
+        "object-keys": "^1.1.1",
+        "object.assign": "^4.1.2",
+        "string.prototype.trimend": "^1.0.4",
+        "string.prototype.trimstart": "^1.0.4",
+        "unbox-primitive": "^1.0.1"
+      }
+    },
+    "es-module-lexer": {
+      "version": "0.7.1",
+      "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-0.7.1.tgz",
+      "integrity": "sha512-MgtWFl5No+4S3TmhDmCz2ObFGm6lEpTnzbQi+Dd+pw4mlTIZTmM2iAs5gRlmx5zS9luzobCSBSI90JM/1/JgOw==",
+      "dev": true,
+      "peer": true
+    },
+    "es-to-primitive": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
+      "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
+      "dev": true,
+      "requires": {
+        "is-callable": "^1.1.4",
+        "is-date-object": "^1.0.1",
+        "is-symbol": "^1.0.2"
+      }
+    },
+    "esbuild": {
+      "version": "0.14.21",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.14.21.tgz",
+      "integrity": "sha512-7WEoNMBJdLN993dr9h0CpFHPRc3yFZD+EAVY9lg6syJJ12gc5fHq8d75QRExuhnMkT2DaRiIKFThRvDWP+fO+A==",
+      "requires": {
+        "esbuild-android-arm64": "0.14.21",
+        "esbuild-darwin-64": "0.14.21",
+        "esbuild-darwin-arm64": "0.14.21",
+        "esbuild-freebsd-64": "0.14.21",
+        "esbuild-freebsd-arm64": "0.14.21",
+        "esbuild-linux-32": "0.14.21",
+        "esbuild-linux-64": "0.14.21",
+        "esbuild-linux-arm": "0.14.21",
+        "esbuild-linux-arm64": "0.14.21",
+        "esbuild-linux-mips64le": "0.14.21",
+        "esbuild-linux-ppc64le": "0.14.21",
+        "esbuild-linux-riscv64": "0.14.21",
+        "esbuild-linux-s390x": "0.14.21",
+        "esbuild-netbsd-64": "0.14.21",
+        "esbuild-openbsd-64": "0.14.21",
+        "esbuild-sunos-64": "0.14.21",
+        "esbuild-windows-32": "0.14.21",
+        "esbuild-windows-64": "0.14.21",
+        "esbuild-windows-arm64": "0.14.21"
+      }
+    },
+    "esbuild-linux-64": {
+      "version": "0.14.21",
+      "resolved": "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.21.tgz",
+      "integrity": "sha512-edZyNOv1ql+kpmlzdqzzDjRQYls+tSyi4QFi+PdBhATJFUqHsnNELWA9vMSzAaInPOEaVUTA5Ml28XFChcy4DA==",
+      "optional": true
+    },
+    "esbuild-sass-plugin": {
+      "version": "2.2.3",
+      "resolved": "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.2.3.tgz",
+      "integrity": "sha512-9Ih8ZDFu7bUoxtFzZNviWFGm6VnrwZytVw+jybfZgjRLt4NEWmr3NapVKVZhh3qgIPsoTm9B+i8wJ0QsGMbc7Q==",
+      "requires": {
+        "esbuild": "^0.14.13",
+        "sass": "^1.49.0"
+      }
+    },
+    "escalade": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
+      "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
+    },
+    "escape-string-regexp": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
+      "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="
+    },
+    "escodegen": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.0.0.tgz",
+      "integrity": "sha512-mmHKys/C8BFUGI+MAWNcSYoORYLMdPzjrknd2Vc+bUsjN5bXcr8EhrNB+UTqfL1y3I9c4fw2ihgtMPQLBRiQxw==",
+      "dev": true,
+      "requires": {
+        "esprima": "^4.0.1",
+        "estraverse": "^5.2.0",
+        "esutils": "^2.0.2",
+        "optionator": "^0.8.1",
+        "source-map": "~0.6.1"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.3.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+          "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+          "dev": true
+        }
+      }
+    },
+    "eslint": {
+      "version": "8.11.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.11.0.tgz",
+      "integrity": "sha512-/KRpd9mIRg2raGxHRGwW9ZywYNAClZrHjdueHcrVDuO3a6bj83eoTirCCk0M0yPwOjWYKHwRVRid+xK4F/GHgA==",
+      "dev": true,
+      "requires": {
+        "@eslint/eslintrc": "^1.2.1",
+        "@humanwhocodes/config-array": "^0.9.2",
+        "ajv": "^6.10.0",
+        "chalk": "^4.0.0",
+        "cross-spawn": "^7.0.2",
+        "debug": "^4.3.2",
+        "doctrine": "^3.0.0",
+        "escape-string-regexp": "^4.0.0",
+        "eslint-scope": "^7.1.1",
+        "eslint-utils": "^3.0.0",
+        "eslint-visitor-keys": "^3.3.0",
+        "espree": "^9.3.1",
+        "esquery": "^1.4.0",
+        "esutils": "^2.0.2",
+        "fast-deep-equal": "^3.1.3",
+        "file-entry-cache": "^6.0.1",
+        "functional-red-black-tree": "^1.0.1",
+        "glob-parent": "^6.0.1",
+        "globals": "^13.6.0",
+        "ignore": "^5.2.0",
+        "import-fresh": "^3.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-glob": "^4.0.0",
+        "js-yaml": "^4.1.0",
+        "json-stable-stringify-without-jsonify": "^1.0.1",
+        "levn": "^0.4.1",
+        "lodash.merge": "^4.6.2",
+        "minimatch": "^3.0.4",
+        "natural-compare": "^1.4.0",
+        "optionator": "^0.9.1",
+        "regexpp": "^3.2.0",
+        "strip-ansi": "^6.0.1",
+        "strip-json-comments": "^3.1.0",
+        "text-table": "^0.2.0",
+        "v8-compile-cache": "^2.0.3"
+      },
+      "dependencies": {
+        "escape-string-regexp": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+          "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+          "dev": true
+        },
+        "eslint-scope": {
+          "version": "7.1.1",
+          "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.1.1.tgz",
+          "integrity": "sha512-QKQM/UXpIiHcLqJ5AOyIW7XZmzjkzQXYE54n1++wb0u9V/abW3l9uQnxX8Z5Xd18xyKIMTUAyQ0k1e8pz6LUrw==",
+          "dev": true,
+          "requires": {
+            "esrecurse": "^4.3.0",
+            "estraverse": "^5.2.0"
+          }
+        },
+        "estraverse": {
+          "version": "5.3.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+          "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+          "dev": true
+        },
+        "glob-parent": {
+          "version": "6.0.2",
+          "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+          "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+          "dev": true,
+          "requires": {
+            "is-glob": "^4.0.3"
+          }
+        },
+        "globals": {
+          "version": "13.13.0",
+          "resolved": "https://registry.npmjs.org/globals/-/globals-13.13.0.tgz",
+          "integrity": "sha512-EQ7Q18AJlPwp3vUDL4mKA0KXrXyNIQyWon6T6XQiBQF0XHvRsiCSrWmmeATpUzdJN2HhWZU6Pdl0a9zdep5p6A==",
+          "dev": true,
+          "requires": {
+            "type-fest": "^0.20.2"
+          }
+        },
+        "levn": {
+          "version": "0.4.1",
+          "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+          "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+          "dev": true,
+          "requires": {
+            "prelude-ls": "^1.2.1",
+            "type-check": "~0.4.0"
+          }
+        },
+        "optionator": {
+          "version": "0.9.1",
+          "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.1.tgz",
+          "integrity": "sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==",
+          "dev": true,
+          "requires": {
+            "deep-is": "^0.1.3",
+            "fast-levenshtein": "^2.0.6",
+            "levn": "^0.4.1",
+            "prelude-ls": "^1.2.1",
+            "type-check": "^0.4.0",
+            "word-wrap": "^1.2.3"
+          }
+        },
+        "prelude-ls": {
+          "version": "1.2.1",
+          "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+          "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+          "dev": true
+        },
+        "type-check": {
+          "version": "0.4.0",
+          "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+          "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+          "dev": true,
+          "requires": {
+            "prelude-ls": "^1.2.1"
+          }
+        },
+        "type-fest": {
+          "version": "0.20.2",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz",
+          "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-config-google": {
+      "version": "0.14.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-google/-/eslint-config-google-0.14.0.tgz",
+      "integrity": "sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-config-prettier": {
+      "version": "8.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-8.5.0.tgz",
+      "integrity": "sha512-obmWKLUNCnhtQRKc+tmnYuQl0pFU1ibYJQ5BGhTVB08bHe9wC8qUeG7c08dj9XX+AuPj1YSGSQIHl1pnDHZR0Q==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-import-resolver-node": {
+      "version": "0.3.6",
+      "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.6.tgz",
+      "integrity": "sha512-0En0w03NRVMn9Uiyn8YRPDKvWjxCWkslUEhGNTdGx15RvPJYQ+lbOlqrlNI2vEAs4pDYK4f/HN2TbDmk5TP0iw==",
+      "dev": true,
+      "requires": {
+        "debug": "^3.2.7",
+        "resolve": "^1.20.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.2.7",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+          "dev": true,
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        }
+      }
+    },
+    "eslint-module-utils": {
+      "version": "2.7.3",
+      "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.7.3.tgz",
+      "integrity": "sha512-088JEC7O3lDZM9xGe0RerkOMd0EjFl+Yvd1jPWIkMT5u3H9+HC34mWWPnqPrN13gieT9pBOO+Qt07Nb/6TresQ==",
+      "dev": true,
+      "requires": {
+        "debug": "^3.2.7",
+        "find-up": "^2.1.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "3.2.7",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
+          "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
+          "dev": true,
+          "requires": {
+            "ms": "^2.1.1"
+          }
+        },
+        "find-up": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz",
+          "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=",
+          "dev": true,
+          "requires": {
+            "locate-path": "^2.0.0"
+          }
+        },
+        "locate-path": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz",
+          "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=",
+          "dev": true,
+          "requires": {
+            "p-locate": "^2.0.0",
+            "path-exists": "^3.0.0"
+          }
+        },
+        "p-limit": {
+          "version": "1.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz",
+          "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==",
+          "dev": true,
+          "requires": {
+            "p-try": "^1.0.0"
+          }
+        },
+        "p-locate": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz",
+          "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=",
+          "dev": true,
+          "requires": {
+            "p-limit": "^1.1.0"
+          }
+        },
+        "p-try": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz",
+          "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=",
+          "dev": true
+        },
+        "path-exists": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
+          "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-import": {
+      "version": "2.25.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.25.4.tgz",
+      "integrity": "sha512-/KJBASVFxpu0xg1kIBn9AUa8hQVnszpwgE7Ld0lKAlx7Ie87yzEzCgSkekt+le/YVhiaosO4Y14GDAOc41nfxA==",
+      "dev": true,
+      "requires": {
+        "array-includes": "^3.1.4",
+        "array.prototype.flat": "^1.2.5",
+        "debug": "^2.6.9",
+        "doctrine": "^2.1.0",
+        "eslint-import-resolver-node": "^0.3.6",
+        "eslint-module-utils": "^2.7.2",
+        "has": "^1.0.3",
+        "is-core-module": "^2.8.0",
+        "is-glob": "^4.0.3",
+        "minimatch": "^3.0.4",
+        "object.values": "^1.1.5",
+        "resolve": "^1.20.0",
+        "tsconfig-paths": "^3.12.0"
+      },
+      "dependencies": {
+        "debug": {
+          "version": "2.6.9",
+          "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+          "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+          "dev": true,
+          "requires": {
+            "ms": "2.0.0"
+          }
+        },
+        "doctrine": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+          "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+          "dev": true,
+          "requires": {
+            "esutils": "^2.0.2"
+          }
+        },
+        "ms": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+          "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-jest": {
+      "version": "26.1.3",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-26.1.3.tgz",
+      "integrity": "sha512-Pju+T7MFpo5VFhFlwrkK/9jRUu18r2iugvgyrWOnnGRaVTFFmFXp+xFJpHyqmjjLmGJPKLeEFLVTAxezkApcpQ==",
+      "dev": true,
+      "requires": {
+        "@typescript-eslint/utils": "^5.10.0"
+      }
+    },
+    "eslint-plugin-jsx-a11y": {
+      "version": "6.5.1",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.5.1.tgz",
+      "integrity": "sha512-sVCFKX9fllURnXT2JwLN5Qgo24Ug5NF6dxhkmxsMEUZhXRcGg+X3e1JbJ84YePQKBl5E0ZjAH5Q4rkdcGY99+g==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.16.3",
+        "aria-query": "^4.2.2",
+        "array-includes": "^3.1.4",
+        "ast-types-flow": "^0.0.7",
+        "axe-core": "^4.3.5",
+        "axobject-query": "^2.2.0",
+        "damerau-levenshtein": "^1.0.7",
+        "emoji-regex": "^9.2.2",
+        "has": "^1.0.3",
+        "jsx-ast-utils": "^3.2.1",
+        "language-tags": "^1.0.5",
+        "minimatch": "^3.0.4"
+      },
+      "dependencies": {
+        "aria-query": {
+          "version": "4.2.2",
+          "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-4.2.2.tgz",
+          "integrity": "sha512-o/HelwhuKpTj/frsOsbNLNgnNGVIFsVP/SW2BSF14gVl7kAfMOJ6/8wUAUvG1R1NHKrfG+2sHZTu0yauT1qBrA==",
+          "dev": true,
+          "requires": {
+            "@babel/runtime": "^7.10.2",
+            "@babel/runtime-corejs3": "^7.10.2"
+          }
+        },
+        "emoji-regex": {
+          "version": "9.2.2",
+          "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
+          "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-prettier": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-4.0.0.tgz",
+      "integrity": "sha512-98MqmCJ7vJodoQK359bqQWaxOE0CS8paAz/GgjaZLyex4TTk3g9HugoO89EqWCrFiOqn9EVvcoo7gZzONCWVwQ==",
+      "dev": true,
+      "requires": {
+        "prettier-linter-helpers": "^1.0.0"
+      }
+    },
+    "eslint-plugin-react": {
+      "version": "7.29.4",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.29.4.tgz",
+      "integrity": "sha512-CVCXajliVh509PcZYRFyu/BoUEz452+jtQJq2b3Bae4v3xBUWPLCmtmBM+ZinG4MzwmxJgJ2M5rMqhqLVn7MtQ==",
+      "dev": true,
+      "requires": {
+        "array-includes": "^3.1.4",
+        "array.prototype.flatmap": "^1.2.5",
+        "doctrine": "^2.1.0",
+        "estraverse": "^5.3.0",
+        "jsx-ast-utils": "^2.4.1 || ^3.0.0",
+        "minimatch": "^3.1.2",
+        "object.entries": "^1.1.5",
+        "object.fromentries": "^2.0.5",
+        "object.hasown": "^1.1.0",
+        "object.values": "^1.1.5",
+        "prop-types": "^15.8.1",
+        "resolve": "^2.0.0-next.3",
+        "semver": "^6.3.0",
+        "string.prototype.matchall": "^4.0.6"
+      },
+      "dependencies": {
+        "doctrine": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
+          "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==",
+          "dev": true,
+          "requires": {
+            "esutils": "^2.0.2"
+          }
+        },
+        "estraverse": {
+          "version": "5.3.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+          "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+          "dev": true
+        },
+        "resolve": {
+          "version": "2.0.0-next.3",
+          "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.3.tgz",
+          "integrity": "sha512-W8LucSynKUIDu9ylraa7ueVZ7hc0uAgJBxVsQSKOXOyle8a93qXhcz+XAXZ8bIq2d6i4Ehddn6Evt+0/UwKk6Q==",
+          "dev": true,
+          "requires": {
+            "is-core-module": "^2.2.0",
+            "path-parse": "^1.0.6"
+          }
+        },
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-plugin-react-hooks": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.5.0.tgz",
+      "integrity": "sha512-8k1gRt7D7h03kd+SAAlzXkQwWK22BnK6GKZG+FJA6BAGy22CFvl8kCIXKpVux0cCxMWDQUPqSok0LKaZ0aOcCw==",
+      "dev": true,
+      "requires": {}
+    },
+    "eslint-scope": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
+      "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
+      "dev": true,
+      "requires": {
+        "esrecurse": "^4.3.0",
+        "estraverse": "^4.1.1"
+      }
+    },
+    "eslint-utils": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/eslint-utils/-/eslint-utils-3.0.0.tgz",
+      "integrity": "sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==",
+      "dev": true,
+      "requires": {
+        "eslint-visitor-keys": "^2.0.0"
+      },
+      "dependencies": {
+        "eslint-visitor-keys": {
+          "version": "2.1.0",
+          "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
+          "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
+          "dev": true
+        }
+      }
+    },
+    "eslint-visitor-keys": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz",
+      "integrity": "sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==",
+      "dev": true
+    },
+    "espree": {
+      "version": "9.3.1",
+      "resolved": "https://registry.npmjs.org/espree/-/espree-9.3.1.tgz",
+      "integrity": "sha512-bvdyLmJMfwkV3NCRl5ZhJf22zBFo1y8bYh3VYb+bfzqNB4Je68P2sSuXyuFquzWLebHpNd2/d5uv7yoP9ISnGQ==",
+      "dev": true,
+      "requires": {
+        "acorn": "^8.7.0",
+        "acorn-jsx": "^5.3.1",
+        "eslint-visitor-keys": "^3.3.0"
+      }
+    },
+    "esprima": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
+      "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
+      "dev": true
+    },
+    "esquery": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.4.0.tgz",
+      "integrity": "sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.1.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.3.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+          "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+          "dev": true
+        }
+      }
+    },
+    "esrecurse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+      "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+      "dev": true,
+      "requires": {
+        "estraverse": "^5.2.0"
+      },
+      "dependencies": {
+        "estraverse": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.2.0.tgz",
+          "integrity": "sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==",
+          "dev": true
+        }
+      }
+    },
+    "estraverse": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
+      "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
+      "dev": true
+    },
+    "esutils": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+      "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+      "dev": true
+    },
+    "eventemitter2": {
+      "version": "6.4.4",
+      "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-6.4.4.tgz",
+      "integrity": "sha512-HLU3NDY6wARrLCEwyGKRBvuWYyvW6mHYv72SJJAH3iJN3a6eVUvkjFkcxah1bcTgGVBBrFdIopBJPhCQFMLyXw==",
+      "dev": true
+    },
+    "events": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
+      "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
+      "dev": true,
+      "peer": true
+    },
+    "execa": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
+      "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==",
+      "dev": true,
+      "requires": {
+        "cross-spawn": "^7.0.3",
+        "get-stream": "^6.0.0",
+        "human-signals": "^2.1.0",
+        "is-stream": "^2.0.0",
+        "merge-stream": "^2.0.0",
+        "npm-run-path": "^4.0.1",
+        "onetime": "^5.1.2",
+        "signal-exit": "^3.0.3",
+        "strip-final-newline": "^2.0.0"
+      }
+    },
+    "execall": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/execall/-/execall-2.0.0.tgz",
+      "integrity": "sha512-0FU2hZ5Hh6iQnarpRtQurM/aAvp3RIbfvgLHrcqJYzhXyV2KFruhuChf9NC6waAhiUR7FFtlugkI4p7f2Fqlow==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "clone-regexp": "^2.1.0"
+      }
+    },
+    "executable": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/executable/-/executable-4.1.1.tgz",
+      "integrity": "sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==",
+      "dev": true,
+      "requires": {
+        "pify": "^2.2.0"
+      }
+    },
+    "exit": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz",
+      "integrity": "sha1-BjJjj42HfMghB9MKD/8aF8uhzQw=",
+      "dev": true
+    },
+    "expect": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/expect/-/expect-27.5.1.tgz",
+      "integrity": "sha512-E1q5hSUG2AmYQwQJ041nvgpkODHQvB+RKlB4IYdru6uJsyFTRyZAP463M+1lINorwbqAmUggi6+WwkD8lCS/Dw==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1"
+      }
+    },
+    "extend": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
+      "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
+      "dev": true
+    },
+    "extract-zip": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+      "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+      "dev": true,
+      "requires": {
+        "@types/yauzl": "^2.9.1",
+        "debug": "^4.1.1",
+        "get-stream": "^5.1.0",
+        "yauzl": "^2.10.0"
+      },
+      "dependencies": {
+        "get-stream": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+          "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+          "dev": true,
+          "requires": {
+            "pump": "^3.0.0"
+          }
+        }
+      }
+    },
+    "extsprintf": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz",
+      "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
+      "dev": true
+    },
+    "fast-deep-equal": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+      "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
+    },
+    "fast-diff": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz",
+      "integrity": "sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w==",
+      "dev": true
+    },
+    "fast-glob": {
+      "version": "3.2.11",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.11.tgz",
+      "integrity": "sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew==",
+      "dev": true,
+      "requires": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.4"
+      }
+    },
+    "fast-json-stable-stringify": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+      "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+      "dev": true
+    },
+    "fast-levenshtein": {
+      "version": "2.0.6",
+      "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+      "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=",
+      "dev": true
+    },
+    "fast-shallow-equal": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fast-shallow-equal/-/fast-shallow-equal-1.0.0.tgz",
+      "integrity": "sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw=="
+    },
+    "fastest-levenshtein": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz",
+      "integrity": "sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==",
+      "dev": true,
+      "peer": true
+    },
+    "fastest-stable-stringify": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/fastest-stable-stringify/-/fastest-stable-stringify-2.0.2.tgz",
+      "integrity": "sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q=="
+    },
+    "fastq": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.13.0.tgz",
+      "integrity": "sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw==",
+      "dev": true,
+      "requires": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "fb-watchman": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.1.tgz",
+      "integrity": "sha512-DkPJKQeY6kKwmuMretBhr7G6Vodr7bFwDYTXIkfG1gjvNpaxBTQV3PbXg6bR1c1UP4jPOX0jHUbbHANL9vRjVg==",
+      "dev": true,
+      "requires": {
+        "bser": "2.1.1"
+      }
+    },
+    "fd-slicer": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+      "integrity": "sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=",
+      "dev": true,
+      "requires": {
+        "pend": "~1.2.0"
+      }
+    },
+    "fetch-blob": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.1.5.tgz",
+      "integrity": "sha512-N64ZpKqoLejlrwkIAnb9iLSA3Vx/kjgzpcDhygcqJ2KKjky8nCgUQ+dzXtbrLaWZGZNmNfQTsiQ0weZ1svglHg==",
+      "dev": true,
+      "requires": {
+        "node-domexception": "^1.0.0",
+        "web-streams-polyfill": "^3.0.3"
+      }
+    },
+    "fetch-mock": {
+      "version": "9.11.0",
+      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
+      "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.0.0",
+        "@babel/runtime": "^7.0.0",
+        "core-js": "^3.0.0",
+        "debug": "^4.1.1",
+        "glob-to-regexp": "^0.4.0",
+        "is-subset": "^0.1.1",
+        "lodash.isequal": "^4.5.0",
+        "path-to-regexp": "^2.2.1",
+        "querystring": "^0.2.0",
+        "whatwg-url": "^6.5.0"
+      },
+      "dependencies": {
+        "tr46": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+          "integrity": "sha1-qLE/1r/SSJUZZ0zN5VujaTtwbQk=",
+          "dev": true,
+          "requires": {
+            "punycode": "^2.1.0"
+          }
+        },
+        "webidl-conversions": {
+          "version": "4.0.2",
+          "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+          "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+          "dev": true
+        },
+        "whatwg-url": {
+          "version": "6.5.0",
+          "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
+          "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
+          "dev": true,
+          "requires": {
+            "lodash.sortby": "^4.7.0",
+            "tr46": "^1.0.1",
+            "webidl-conversions": "^4.0.2"
+          }
+        }
+      }
+    },
+    "fetch-mock-jest": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz",
+      "integrity": "sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ==",
+      "dev": true,
+      "requires": {
+        "fetch-mock": "^9.11.0"
+      }
+    },
+    "figures": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz",
+      "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
+      "dev": true,
+      "requires": {
+        "escape-string-regexp": "^1.0.5"
+      }
+    },
+    "file-entry-cache": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz",
+      "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==",
+      "dev": true,
+      "requires": {
+        "flat-cache": "^3.0.4"
+      }
+    },
+    "fill-range": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
+      "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
+      "requires": {
+        "to-regex-range": "^5.0.1"
+      }
+    },
+    "find-root": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz",
+      "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="
+    },
+    "find-up": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
+      "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
+      "dev": true,
+      "requires": {
+        "locate-path": "^5.0.0",
+        "path-exists": "^4.0.0"
+      }
+    },
+    "flat-cache": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.0.4.tgz",
+      "integrity": "sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==",
+      "dev": true,
+      "requires": {
+        "flatted": "^3.1.0",
+        "rimraf": "^3.0.2"
+      }
+    },
+    "flatted": {
+      "version": "3.2.5",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.5.tgz",
+      "integrity": "sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==",
+      "dev": true
+    },
+    "forever-agent": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz",
+      "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=",
+      "dev": true
+    },
+    "form-data": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz",
+      "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==",
+      "dev": true,
+      "requires": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.6",
+        "mime-types": "^2.1.12"
+      }
+    },
+    "formdata-polyfill": {
+      "version": "4.0.10",
+      "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+      "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+      "dev": true,
+      "requires": {
+        "fetch-blob": "^3.1.2"
+      }
+    },
+    "fs-extra": {
+      "version": "9.1.0",
+      "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
+      "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
+      "dev": true,
+      "requires": {
+        "at-least-node": "^1.0.0",
+        "graceful-fs": "^4.2.0",
+        "jsonfile": "^6.0.1",
+        "universalify": "^2.0.0"
+      }
+    },
+    "fs.realpath": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
+      "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8="
+    },
+    "function-bind": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
+      "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
+    },
+    "functional-red-black-tree": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz",
+      "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
+      "dev": true
+    },
+    "gensync": {
+      "version": "1.0.0-beta.2",
+      "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+      "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="
+    },
+    "get-caller-file": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
+      "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
+      "dev": true
+    },
+    "get-intrinsic": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.1.tgz",
+      "integrity": "sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "has": "^1.0.3",
+        "has-symbols": "^1.0.1"
+      }
+    },
+    "get-package-type": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
+      "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
+      "dev": true
+    },
+    "get-stdin": {
+      "version": "8.0.0",
+      "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz",
+      "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==",
+      "dev": true,
+      "peer": true
+    },
+    "get-stream": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
+      "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
+      "dev": true
+    },
+    "get-symbol-description": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz",
+      "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "get-intrinsic": "^1.1.1"
+      }
+    },
+    "getos": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/getos/-/getos-3.2.1.tgz",
+      "integrity": "sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==",
+      "dev": true,
+      "requires": {
+        "async": "^3.2.0"
+      }
+    },
+    "getpass": {
+      "version": "0.1.7",
+      "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz",
+      "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=",
+      "dev": true,
+      "requires": {
+        "assert-plus": "^1.0.0"
+      }
+    },
+    "glob": {
+      "version": "7.1.7",
+      "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz",
+      "integrity": "sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==",
+      "requires": {
+        "fs.realpath": "^1.0.0",
+        "inflight": "^1.0.4",
+        "inherits": "2",
+        "minimatch": "^3.0.4",
+        "once": "^1.3.0",
+        "path-is-absolute": "^1.0.0"
+      }
+    },
+    "glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "requires": {
+        "is-glob": "^4.0.1"
+      }
+    },
+    "glob-to-regexp": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+      "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
+      "dev": true
+    },
+    "global-dirs": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.0.tgz",
+      "integrity": "sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==",
+      "dev": true,
+      "requires": {
+        "ini": "2.0.0"
+      }
+    },
+    "global-modules": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-2.0.0.tgz",
+      "integrity": "sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "global-prefix": "^3.0.0"
+      }
+    },
+    "global-prefix": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-3.0.0.tgz",
+      "integrity": "sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "ini": "^1.3.5",
+        "kind-of": "^6.0.2",
+        "which": "^1.3.1"
+      },
+      "dependencies": {
+        "ini": {
+          "version": "1.3.8",
+          "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+          "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+          "dev": true,
+          "peer": true
+        },
+        "which": {
+          "version": "1.3.1",
+          "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz",
+          "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==",
+          "dev": true,
+          "peer": true,
+          "requires": {
+            "isexe": "^2.0.0"
+          }
+        }
+      }
+    },
+    "globals": {
+      "version": "11.12.0",
+      "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",
+      "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="
+    },
+    "globby": {
+      "version": "11.1.0",
+      "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz",
+      "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==",
+      "dev": true,
+      "requires": {
+        "array-union": "^2.1.0",
+        "dir-glob": "^3.0.1",
+        "fast-glob": "^3.2.9",
+        "ignore": "^5.2.0",
+        "merge2": "^1.4.1",
+        "slash": "^3.0.0"
+      }
+    },
+    "globjoin": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/globjoin/-/globjoin-0.1.4.tgz",
+      "integrity": "sha1-L0SUrIkZ43Z8XLtpHp9GMyQoXUM=",
+      "dev": true,
+      "peer": true
+    },
+    "graceful-fs": {
+      "version": "4.2.9",
+      "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
+      "integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==",
+      "dev": true
+    },
+    "har-schema": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
+      "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=",
+      "dev": true
+    },
+    "har-validator": {
+      "version": "5.1.5",
+      "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz",
+      "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==",
+      "dev": true,
+      "requires": {
+        "ajv": "^6.12.3",
+        "har-schema": "^2.0.0"
+      }
+    },
+    "hard-rejection": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
+      "integrity": "sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==",
+      "dev": true,
+      "peer": true
+    },
+    "harmony-reflect": {
+      "version": "1.6.2",
+      "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.2.tgz",
+      "integrity": "sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==",
+      "dev": true
+    },
+    "has": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
+      "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
+      "requires": {
+        "function-bind": "^1.1.1"
+      }
+    },
+    "has-bigints": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.1.tgz",
+      "integrity": "sha512-LSBS2LjbNBTf6287JEbEzvJgftkF5qFkmCo9hDRpAzKhUOlJ+hx8dd4USs00SgsUNwc4617J9ki5YtEClM2ffA==",
+      "dev": true
+    },
+    "has-flag": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+      "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+      "dev": true
+    },
+    "has-symbols": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz",
+      "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==",
+      "dev": true
+    },
+    "has-tostringtag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz",
+      "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==",
+      "dev": true,
+      "requires": {
+        "has-symbols": "^1.0.2"
+      }
+    },
+    "history": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmjs.org/history/-/history-5.3.0.tgz",
+      "integrity": "sha512-ZqaKwjjrAYUYfLG+htGaIIZ4nioX2L70ZUMIFysS3xvBsSG4x/n1V6TXV3N8ZYNuFGlDirFg32T7B6WOUPDYcQ==",
+      "requires": {
+        "@babel/runtime": "^7.7.6"
+      }
+    },
+    "hoist-non-react-statics": {
+      "version": "3.3.2",
+      "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
+      "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
+      "requires": {
+        "react-is": "^16.7.0"
+      },
+      "dependencies": {
+        "react-is": {
+          "version": "16.13.1",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+          "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+        }
+      }
+    },
+    "hosted-git-info": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
+      "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "lru-cache": "^6.0.0"
+      }
+    },
+    "html-encoding-sniffer": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz",
+      "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==",
+      "dev": true,
+      "requires": {
+        "whatwg-encoding": "^1.0.5"
+      }
+    },
+    "html-escaper": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
+      "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==",
+      "dev": true
+    },
+    "html-tags": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/html-tags/-/html-tags-3.2.0.tgz",
+      "integrity": "sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg==",
+      "dev": true,
+      "peer": true
+    },
+    "http-proxy-agent": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz",
+      "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==",
+      "dev": true,
+      "requires": {
+        "@tootallnate/once": "1",
+        "agent-base": "6",
+        "debug": "4"
+      }
+    },
+    "http-signature": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz",
+      "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=",
+      "dev": true,
+      "requires": {
+        "assert-plus": "^1.0.0",
+        "jsprim": "^1.2.2",
+        "sshpk": "^1.7.0"
+      }
+    },
+    "https-proxy-agent": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz",
+      "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==",
+      "dev": true,
+      "requires": {
+        "agent-base": "6",
+        "debug": "4"
+      }
+    },
+    "human-signals": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
+      "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==",
+      "dev": true
+    },
+    "hyphenate-style-name": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz",
+      "integrity": "sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ=="
+    },
+    "iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "dev": true,
+      "requires": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      }
+    },
+    "icss-utils": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz",
+      "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==",
+      "dev": true,
+      "requires": {}
+    },
+    "identity-obj-proxy": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
+      "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=",
+      "dev": true,
+      "requires": {
+        "harmony-reflect": "^1.4.6"
+      }
+    },
+    "ignore": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.0.tgz",
+      "integrity": "sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==",
+      "dev": true
+    },
+    "immutable": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.0.0.tgz",
+      "integrity": "sha512-zIE9hX70qew5qTUjSS7wi1iwj/l7+m54KWU247nhM3v806UdGj1yDndXj+IOYxxtW9zyLI+xqFNZjTuDaLUqFw=="
+    },
+    "import-fresh": {
+      "version": "3.3.0",
+      "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz",
+      "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==",
+      "requires": {
+        "parent-module": "^1.0.0",
+        "resolve-from": "^4.0.0"
+      }
+    },
+    "import-lazy": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz",
+      "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==",
+      "dev": true,
+      "peer": true
+    },
+    "import-local": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz",
+      "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==",
+      "dev": true,
+      "requires": {
+        "pkg-dir": "^4.2.0",
+        "resolve-cwd": "^3.0.0"
+      }
+    },
+    "imurmurhash": {
+      "version": "0.1.4",
+      "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+      "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=",
+      "dev": true
+    },
+    "indent-string": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+      "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+      "dev": true
+    },
+    "inflight": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
+      "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=",
+      "requires": {
+        "once": "^1.3.0",
+        "wrappy": "1"
+      }
+    },
+    "inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
+    },
+    "ini": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz",
+      "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
+      "dev": true
+    },
+    "inline-style-prefixer": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/inline-style-prefixer/-/inline-style-prefixer-6.0.1.tgz",
+      "integrity": "sha512-AsqazZ8KcRzJ9YPN1wMH2aNM7lkWQ8tSPrW5uDk1ziYwiAPWSZnUsC7lfZq+BDqLqz0B4Pho5wscWcJzVvRzDQ==",
+      "requires": {
+        "css-in-js-utils": "^2.0.0"
+      }
+    },
+    "internal-slot": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.3.tgz",
+      "integrity": "sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==",
+      "dev": true,
+      "requires": {
+        "get-intrinsic": "^1.1.0",
+        "has": "^1.0.3",
+        "side-channel": "^1.0.4"
+      }
+    },
+    "is-arrayish": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz",
+      "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0="
+    },
+    "is-bigint": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz",
+      "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==",
+      "dev": true,
+      "requires": {
+        "has-bigints": "^1.0.1"
+      }
+    },
+    "is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "requires": {
+        "binary-extensions": "^2.0.0"
+      }
+    },
+    "is-boolean-object": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz",
+      "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-callable": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.4.tgz",
+      "integrity": "sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==",
+      "dev": true
+    },
+    "is-ci": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.0.tgz",
+      "integrity": "sha512-kDXyttuLeslKAHYL/K28F2YkM3x5jvFPEw3yXbRptXydjD9rpLEz+C5K5iutY9ZiUu6AP41JdvRQwF4Iqs4ZCQ==",
+      "dev": true,
+      "requires": {
+        "ci-info": "^3.1.1"
+      }
+    },
+    "is-core-module": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.8.1.tgz",
+      "integrity": "sha512-SdNCUs284hr40hFTFP6l0IfZ/RSrMXF3qgoRHd3/79unUTvrFO/JoXwkGm+5J/Oe3E/b5GsnG330uUNgRpu1PA==",
+      "requires": {
+        "has": "^1.0.3"
+      }
+    },
+    "is-date-object": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz",
+      "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI="
+    },
+    "is-fullwidth-code-point": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
+      "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
+      "dev": true
+    },
+    "is-generator-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz",
+      "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==",
+      "dev": true
+    },
+    "is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "requires": {
+        "is-extglob": "^2.1.1"
+      }
+    },
+    "is-installed-globally": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz",
+      "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==",
+      "dev": true,
+      "requires": {
+        "global-dirs": "^3.0.0",
+        "is-path-inside": "^3.0.2"
+      }
+    },
+    "is-negative-zero": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz",
+      "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==",
+      "dev": true
+    },
+    "is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="
+    },
+    "is-number-object": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.6.tgz",
+      "integrity": "sha512-bEVOqiRcvo3zO1+G2lVMy+gkkEm9Yh7cDMRusKKu5ZJKPUYSJwICTKZrNKHA2EbSP0Tu0+6B/emsYNHZyn6K8g==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-path-inside": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz",
+      "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==",
+      "dev": true
+    },
+    "is-plain-obj": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz",
+      "integrity": "sha1-caUMhCnfync8kqOQpKA7OfzVHT4=",
+      "dev": true,
+      "peer": true
+    },
+    "is-plain-object": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
+      "integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
+      "dev": true,
+      "peer": true
+    },
+    "is-potential-custom-element-name": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+      "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+      "dev": true
+    },
+    "is-regex": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz",
+      "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-regexp": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-regexp/-/is-regexp-2.1.0.tgz",
+      "integrity": "sha512-OZ4IlER3zmRIoB9AqNhEggVxqIH4ofDns5nRrPS6yQxXE1TPCUpFznBfRQmQa8uC+pXqjMnukiJBxCisIxiLGA==",
+      "dev": true,
+      "peer": true
+    },
+    "is-shared-array-buffer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz",
+      "integrity": "sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==",
+      "dev": true
+    },
+    "is-stream": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
+      "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
+      "dev": true
+    },
+    "is-string": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz",
+      "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==",
+      "dev": true,
+      "requires": {
+        "has-tostringtag": "^1.0.0"
+      }
+    },
+    "is-subset": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
+      "integrity": "sha1-ilkRfZMt4d4A8kX83TnOQ/HpOaY=",
+      "dev": true
+    },
+    "is-symbol": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz",
+      "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==",
+      "dev": true,
+      "requires": {
+        "has-symbols": "^1.0.2"
+      }
+    },
+    "is-typedarray": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
+      "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=",
+      "dev": true
+    },
+    "is-unicode-supported": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz",
+      "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==",
+      "dev": true
+    },
+    "is-weakref": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz",
+      "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2"
+      }
+    },
+    "isexe": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+      "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=",
+      "dev": true
+    },
+    "isobject": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
+      "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8="
+    },
+    "isstream": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
+      "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=",
+      "dev": true
+    },
+    "istanbul-lib-coverage": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz",
+      "integrity": "sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==",
+      "dev": true
+    },
+    "istanbul-lib-instrument": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.1.0.tgz",
+      "integrity": "sha512-czwUz525rkOFDJxfKK6mYfIs9zBKILyrZQxjz3ABhjQXhbhFsSbo1HW/BFcsDnfJYJWA6thRR5/TUY2qs5W99Q==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.12.3",
+        "@babel/parser": "^7.14.7",
+        "@istanbuljs/schema": "^0.1.2",
+        "istanbul-lib-coverage": "^3.2.0",
+        "semver": "^6.3.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "istanbul-lib-report": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz",
+      "integrity": "sha512-wcdi+uAKzfiGT2abPpKZ0hSU1rGQjUQnLvtY5MpQ7QCTahD3VODhcu4wcfY1YtkGaDD5yuydOLINXsfbus9ROw==",
+      "dev": true,
+      "requires": {
+        "istanbul-lib-coverage": "^3.0.0",
+        "make-dir": "^3.0.0",
+        "supports-color": "^7.1.0"
+      },
+      "dependencies": {
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "istanbul-lib-source-maps": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz",
+      "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==",
+      "dev": true,
+      "requires": {
+        "debug": "^4.1.1",
+        "istanbul-lib-coverage": "^3.0.0",
+        "source-map": "^0.6.1"
+      }
+    },
+    "istanbul-reports": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.4.tgz",
+      "integrity": "sha512-r1/DshN4KSE7xWEknZLLLLDn5CJybV3nw01VTkp6D5jzLuELlcbudfj/eSQFvrKsJuTVCGnePO7ho82Nw9zzfw==",
+      "dev": true,
+      "requires": {
+        "html-escaper": "^2.0.0",
+        "istanbul-lib-report": "^3.0.0"
+      }
+    },
+    "jest": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest/-/jest-27.5.1.tgz",
+      "integrity": "sha512-Yn0mADZB89zTtjkPJEXwrac3LHudkQMR+Paqa8uxJHCBr9agxztUifWCyiYrjhMPBoUVBjyny0I7XH6ozDr7QQ==",
+      "dev": true,
+      "requires": {
+        "@jest/core": "^27.5.1",
+        "import-local": "^3.0.2",
+        "jest-cli": "^27.5.1"
+      }
+    },
+    "jest-changed-files": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-27.5.1.tgz",
+      "integrity": "sha512-buBLMiByfWGCoMsLLzGUUSpAmIAGnbR2KJoMN10ziLhOLvP4e0SlypHnAel8iqQXTrcbmfEY9sSqae5sgUsTvw==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "execa": "^5.0.0",
+        "throat": "^6.0.1"
+      }
+    },
+    "jest-circus": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-27.5.1.tgz",
+      "integrity": "sha512-D95R7x5UtlMA5iBYsOHFFbMD/GVA4R/Kdq15f7xYWUfWHBto9NYRsOvnSauTgdF+ogCpJ4tyKOXhUifxS65gdw==",
+      "dev": true,
+      "requires": {
+        "@jest/environment": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "co": "^4.6.0",
+        "dedent": "^0.7.0",
+        "expect": "^27.5.1",
+        "is-generator-fn": "^2.0.0",
+        "jest-each": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "pretty-format": "^27.5.1",
+        "slash": "^3.0.0",
+        "stack-utils": "^2.0.3",
+        "throat": "^6.0.1"
+      }
+    },
+    "jest-cli": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-27.5.1.tgz",
+      "integrity": "sha512-Hc6HOOwYq4/74/c62dEE3r5elx8wjYqxY0r0G/nFrLDPMFRu6RA/u8qINOIkvhxG7mMQ5EJsOGfRpI8L6eFUVw==",
+      "dev": true,
+      "requires": {
+        "@jest/core": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "exit": "^0.1.2",
+        "graceful-fs": "^4.2.9",
+        "import-local": "^3.0.2",
+        "jest-config": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "prompts": "^2.0.1",
+        "yargs": "^16.2.0"
+      }
+    },
+    "jest-config": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-27.5.1.tgz",
+      "integrity": "sha512-5sAsjm6tGdsVbW9ahcChPAFCk4IlkQUknH5AvKjuLTSlcO/wCZKyFdn7Rg0EkC+OGgWODEy2hDpWB1PgzH0JNA==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.8.0",
+        "@jest/test-sequencer": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "babel-jest": "^27.5.1",
+        "chalk": "^4.0.0",
+        "ci-info": "^3.2.0",
+        "deepmerge": "^4.2.2",
+        "glob": "^7.1.1",
+        "graceful-fs": "^4.2.9",
+        "jest-circus": "^27.5.1",
+        "jest-environment-jsdom": "^27.5.1",
+        "jest-environment-node": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "jest-jasmine2": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-runner": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "parse-json": "^5.2.0",
+        "pretty-format": "^27.5.1",
+        "slash": "^3.0.0",
+        "strip-json-comments": "^3.1.1"
+      }
+    },
+    "jest-diff": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-27.5.1.tgz",
+      "integrity": "sha512-m0NvkX55LDt9T4mctTEgnZk3fmEg3NRYutvMPWM/0iPnkFj2wIeF45O1718cMSOFO1vINkqmxqD8vE37uTEbqw==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.0.0",
+        "diff-sequences": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      }
+    },
+    "jest-docblock": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-27.5.1.tgz",
+      "integrity": "sha512-rl7hlABeTsRYxKiUfpHrQrG4e2obOiTQWfMEH3PxPjOtdsfLQO4ReWSZaQ7DETm4xu07rl4q/h4zcKXyU0/OzQ==",
+      "dev": true,
+      "requires": {
+        "detect-newline": "^3.0.0"
+      }
+    },
+    "jest-each": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-27.5.1.tgz",
+      "integrity": "sha512-1Ff6p+FbhT/bXQnEouYy00bkNSY7OUpfIcmdl8vZ31A1UUaurOLPA8a8BbJOF2RDUElwJhmeaV7LnagI+5UwNQ==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "jest-get-type": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      }
+    },
+    "jest-environment-jsdom": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-27.5.1.tgz",
+      "integrity": "sha512-TFBvkTC1Hnnnrka/fUb56atfDtJ9VMZ94JkjTbggl1PEpwrYtUBKMezB3inLmWqQsXYLcMwNoDQwoBTAvFfsfw==",
+      "dev": true,
+      "requires": {
+        "@jest/environment": "^27.5.1",
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "jest-mock": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jsdom": "^16.6.0"
+      }
+    },
+    "jest-environment-node": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-27.5.1.tgz",
+      "integrity": "sha512-Jt4ZUnxdOsTGwSRAfKEnE6BcwsSPNOijjwifq5sDFSA2kesnXTvNqKHYgM0hDq3549Uf/KzdXNYn4wMZJPlFLw==",
+      "dev": true,
+      "requires": {
+        "@jest/environment": "^27.5.1",
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "jest-mock": "^27.5.1",
+        "jest-util": "^27.5.1"
+      }
+    },
+    "jest-get-type": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-27.5.1.tgz",
+      "integrity": "sha512-2KY95ksYSaK7DMBWQn6dQz3kqAf3BB64y2udeG+hv4KfSOb9qwcYQstTJc1KCbsix+wLZWZYN8t7nwX3GOBLRw==",
+      "dev": true
+    },
+    "jest-haste-map": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-27.5.1.tgz",
+      "integrity": "sha512-7GgkZ4Fw4NFbMSDSpZwXeBiIbx+t/46nJ2QitkOjvwPYyZmqttu2TDSimMHP1EkPOi4xUZAN1doE5Vd25H4Jng==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "@types/graceful-fs": "^4.1.2",
+        "@types/node": "*",
+        "anymatch": "^3.0.3",
+        "fb-watchman": "^2.0.0",
+        "fsevents": "^2.3.2",
+        "graceful-fs": "^4.2.9",
+        "jest-regex-util": "^27.5.1",
+        "jest-serializer": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-worker": "^27.5.1",
+        "micromatch": "^4.0.4",
+        "walker": "^1.0.7"
+      }
+    },
+    "jest-jasmine2": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-27.5.1.tgz",
+      "integrity": "sha512-jtq7VVyG8SqAorDpApwiJJImd0V2wv1xzdheGHRGyuT7gZm6gG47QEskOlzsN1PG/6WNaCo5pmwMHDf3AkG2pQ==",
+      "dev": true,
+      "requires": {
+        "@jest/environment": "^27.5.1",
+        "@jest/source-map": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "co": "^4.6.0",
+        "expect": "^27.5.1",
+        "is-generator-fn": "^2.0.0",
+        "jest-each": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "pretty-format": "^27.5.1",
+        "throat": "^6.0.1"
+      }
+    },
+    "jest-leak-detector": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-27.5.1.tgz",
+      "integrity": "sha512-POXfWAMvfU6WMUXftV4HolnJfnPOGEu10fscNCA76KBpRRhcMN2c8d3iT2pxQS3HLbA+5X4sOUPzYO2NUyIlHQ==",
+      "dev": true,
+      "requires": {
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      }
+    },
+    "jest-matcher-utils": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-27.5.1.tgz",
+      "integrity": "sha512-z2uTx/T6LBaCoNWNFWwChLBKYxTMcGBRjAt+2SbP929/Fflb9aa5LGma654Rz8z9HLxsrUaYzxE9T/EFIL/PAw==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.0.0",
+        "jest-diff": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "pretty-format": "^27.5.1"
+      }
+    },
+    "jest-message-util": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-27.5.1.tgz",
+      "integrity": "sha512-rMyFe1+jnyAAf+NHwTclDz0eAaLkVDdKVHHBFWsBWHnnh5YeJMNWWsv7AbFYXfK3oTqvL7VTWkhNLu1jX24D+g==",
+      "dev": true,
+      "requires": {
+        "@babel/code-frame": "^7.12.13",
+        "@jest/types": "^27.5.1",
+        "@types/stack-utils": "^2.0.0",
+        "chalk": "^4.0.0",
+        "graceful-fs": "^4.2.9",
+        "micromatch": "^4.0.4",
+        "pretty-format": "^27.5.1",
+        "slash": "^3.0.0",
+        "stack-utils": "^2.0.3"
+      }
+    },
+    "jest-mock": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-27.5.1.tgz",
+      "integrity": "sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "@types/node": "*"
+      }
+    },
+    "jest-pnp-resolver": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.2.tgz",
+      "integrity": "sha512-olV41bKSMm8BdnuMsewT4jqlZ8+3TCARAXjZGT9jcoSnrfUnRCqnMoF9XEeoWjbzObpqF9dRhHQj0Xb9QdF6/w==",
+      "dev": true,
+      "requires": {}
+    },
+    "jest-regex-util": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-27.5.1.tgz",
+      "integrity": "sha512-4bfKq2zie+x16okqDXjXn9ql2B0dScQu+vcwe4TvFVhkVyuWLqpZrZtXxLLWoXYgn0E87I6r6GRYHF7wFZBUvg==",
+      "dev": true
+    },
+    "jest-resolve": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-27.5.1.tgz",
+      "integrity": "sha512-FFDy8/9E6CV83IMbDpcjOhumAQPDyETnU2KZ1O98DwTnz8AOBsW/Xv3GySr1mOZdItLR+zDZ7I/UdTFbgSOVCw==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-pnp-resolver": "^1.2.2",
+        "jest-util": "^27.5.1",
+        "jest-validate": "^27.5.1",
+        "resolve": "^1.20.0",
+        "resolve.exports": "^1.1.0",
+        "slash": "^3.0.0"
+      }
+    },
+    "jest-resolve-dependencies": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-27.5.1.tgz",
+      "integrity": "sha512-QQOOdY4PE39iawDn5rzbIePNigfe5B9Z91GDD1ae/xNDlu9kaat8QQ5EKnNmVWPV54hUdxCVwwj6YMgR2O7IOg==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-snapshot": "^27.5.1"
+      }
+    },
+    "jest-runner": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-27.5.1.tgz",
+      "integrity": "sha512-g4NPsM4mFCOwFKXO4p/H/kWGdJp9V8kURY2lX8Me2drgXqG7rrZAx5kv+5H7wtt/cdFIjhqYx1HrlqWHaOvDaQ==",
+      "dev": true,
+      "requires": {
+        "@jest/console": "^27.5.1",
+        "@jest/environment": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "emittery": "^0.8.1",
+        "graceful-fs": "^4.2.9",
+        "jest-docblock": "^27.5.1",
+        "jest-environment-jsdom": "^27.5.1",
+        "jest-environment-node": "^27.5.1",
+        "jest-haste-map": "^27.5.1",
+        "jest-leak-detector": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-runtime": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "jest-worker": "^27.5.1",
+        "source-map-support": "^0.5.6",
+        "throat": "^6.0.1"
+      }
+    },
+    "jest-runtime": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-27.5.1.tgz",
+      "integrity": "sha512-o7gxw3Gf+H2IGt8fv0RiyE1+r83FJBRruoA+FXrlHw6xEyBsU8ugA6IPfTdVyA0w8HClpbK+DGJxH59UrNMx8A==",
+      "dev": true,
+      "requires": {
+        "@jest/environment": "^27.5.1",
+        "@jest/fake-timers": "^27.5.1",
+        "@jest/globals": "^27.5.1",
+        "@jest/source-map": "^27.5.1",
+        "@jest/test-result": "^27.5.1",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "chalk": "^4.0.0",
+        "cjs-module-lexer": "^1.0.0",
+        "collect-v8-coverage": "^1.0.0",
+        "execa": "^5.0.0",
+        "glob": "^7.1.3",
+        "graceful-fs": "^4.2.9",
+        "jest-haste-map": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-mock": "^27.5.1",
+        "jest-regex-util": "^27.5.1",
+        "jest-resolve": "^27.5.1",
+        "jest-snapshot": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "slash": "^3.0.0",
+        "strip-bom": "^4.0.0"
+      }
+    },
+    "jest-serializer": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-27.5.1.tgz",
+      "integrity": "sha512-jZCyo6iIxO1aqUxpuBlwTDMkzOAJS4a3eYz3YzgxxVQFwLeSA7Jfq5cbqCY+JLvTDrWirgusI/0KwxKMgrdf7w==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*",
+        "graceful-fs": "^4.2.9"
+      }
+    },
+    "jest-snapshot": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-27.5.1.tgz",
+      "integrity": "sha512-yYykXI5a0I31xX67mgeLw1DZ0bJB+gpq5IpSuCAoyDi0+BhgU/RIrL+RTzDmkNTchvDFWKP8lp+w/42Z3us5sA==",
+      "dev": true,
+      "requires": {
+        "@babel/core": "^7.7.2",
+        "@babel/generator": "^7.7.2",
+        "@babel/plugin-syntax-typescript": "^7.7.2",
+        "@babel/traverse": "^7.7.2",
+        "@babel/types": "^7.0.0",
+        "@jest/transform": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/babel__traverse": "^7.0.4",
+        "@types/prettier": "^2.1.5",
+        "babel-preset-current-node-syntax": "^1.0.0",
+        "chalk": "^4.0.0",
+        "expect": "^27.5.1",
+        "graceful-fs": "^4.2.9",
+        "jest-diff": "^27.5.1",
+        "jest-get-type": "^27.5.1",
+        "jest-haste-map": "^27.5.1",
+        "jest-matcher-utils": "^27.5.1",
+        "jest-message-util": "^27.5.1",
+        "jest-util": "^27.5.1",
+        "natural-compare": "^1.4.0",
+        "pretty-format": "^27.5.1",
+        "semver": "^7.3.2"
+      }
+    },
+    "jest-util": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-27.5.1.tgz",
+      "integrity": "sha512-Kv2o/8jNvX1MQ0KGtw480E/w4fBCDOnH6+6DmeKi6LZUIlKA5kwY0YNdlzaWTiVgxqAqik11QyxDOKk543aKXw==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "chalk": "^4.0.0",
+        "ci-info": "^3.2.0",
+        "graceful-fs": "^4.2.9",
+        "picomatch": "^2.2.3"
+      }
+    },
+    "jest-validate": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-27.5.1.tgz",
+      "integrity": "sha512-thkNli0LYTmOI1tDB3FI1S1RTp/Bqyd9pTarJwL87OIBFuqEb5Apv5EaApEudYg4g86e3CT6kM0RowkhtEnCBQ==",
+      "dev": true,
+      "requires": {
+        "@jest/types": "^27.5.1",
+        "camelcase": "^6.2.0",
+        "chalk": "^4.0.0",
+        "jest-get-type": "^27.5.1",
+        "leven": "^3.1.0",
+        "pretty-format": "^27.5.1"
+      }
+    },
+    "jest-watcher": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-27.5.1.tgz",
+      "integrity": "sha512-z676SuD6Z8o8qbmEGhoEUFOM1+jfEiL3DXHK/xgEiG2EyNYfFG60jluWcupY6dATjfEsKQuibReS1djInQnoVw==",
+      "dev": true,
+      "requires": {
+        "@jest/test-result": "^27.5.1",
+        "@jest/types": "^27.5.1",
+        "@types/node": "*",
+        "ansi-escapes": "^4.2.1",
+        "chalk": "^4.0.0",
+        "jest-util": "^27.5.1",
+        "string-length": "^4.0.1"
+      }
+    },
+    "jest-worker": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz",
+      "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==",
+      "dev": true,
+      "requires": {
+        "@types/node": "*",
+        "merge-stream": "^2.0.0",
+        "supports-color": "^8.0.0"
+      }
+    },
+    "js-cookie": {
+      "version": "2.2.1",
+      "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
+      "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
+    },
+    "js-sha3": {
+      "version": "0.8.0",
+      "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
+      "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q=="
+    },
+    "js-tokens": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+      "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
+    },
+    "js-yaml": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+      "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+      "dev": true,
+      "requires": {
+        "argparse": "^2.0.1"
+      }
+    },
+    "jsbn": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
+      "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=",
+      "dev": true
+    },
+    "jsdom": {
+      "version": "16.7.0",
+      "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz",
+      "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==",
+      "dev": true,
+      "requires": {
+        "abab": "^2.0.5",
+        "acorn": "^8.2.4",
+        "acorn-globals": "^6.0.0",
+        "cssom": "^0.4.4",
+        "cssstyle": "^2.3.0",
+        "data-urls": "^2.0.0",
+        "decimal.js": "^10.2.1",
+        "domexception": "^2.0.1",
+        "escodegen": "^2.0.0",
+        "form-data": "^3.0.0",
+        "html-encoding-sniffer": "^2.0.1",
+        "http-proxy-agent": "^4.0.1",
+        "https-proxy-agent": "^5.0.0",
+        "is-potential-custom-element-name": "^1.0.1",
+        "nwsapi": "^2.2.0",
+        "parse5": "6.0.1",
+        "saxes": "^5.0.1",
+        "symbol-tree": "^3.2.4",
+        "tough-cookie": "^4.0.0",
+        "w3c-hr-time": "^1.0.2",
+        "w3c-xmlserializer": "^2.0.0",
+        "webidl-conversions": "^6.1.0",
+        "whatwg-encoding": "^1.0.5",
+        "whatwg-mimetype": "^2.3.0",
+        "whatwg-url": "^8.5.0",
+        "ws": "^7.4.6",
+        "xml-name-validator": "^3.0.0"
+      },
+      "dependencies": {
+        "form-data": {
+          "version": "3.0.1",
+          "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
+          "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
+          "dev": true,
+          "requires": {
+            "asynckit": "^0.4.0",
+            "combined-stream": "^1.0.8",
+            "mime-types": "^2.1.12"
+          }
+        },
+        "tough-cookie": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz",
+          "integrity": "sha512-tHdtEpQCMrc1YLrMaqXXcj6AxhYi/xgit6mZu1+EDWUn+qhUf8wMQoFIy9NXuq23zAwtcB0t/MjACGR18pcRbg==",
+          "dev": true,
+          "requires": {
+            "psl": "^1.1.33",
+            "punycode": "^2.1.1",
+            "universalify": "^0.1.2"
+          }
+        },
+        "universalify": {
+          "version": "0.1.2",
+          "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
+          "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
+          "dev": true
+        },
+        "ws": {
+          "version": "7.5.7",
+          "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.7.tgz",
+          "integrity": "sha512-KMvVuFzpKBuiIXW3E4u3mySRO2/mCHSyZDJQM5NQ9Q9KHWHWh0NHgfbRMLLrceUK5qAL4ytALJbpRMjixFZh8A==",
+          "dev": true,
+          "requires": {}
+        }
+      }
+    },
+    "jsesc": {
+      "version": "2.5.2",
+      "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
+      "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="
+    },
+    "json-parse-better-errors": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
+      "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==",
+      "dev": true,
+      "peer": true
+    },
+    "json-parse-even-better-errors": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
+      "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="
+    },
+    "json-schema": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz",
+      "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==",
+      "dev": true
+    },
+    "json-schema-traverse": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+      "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+      "dev": true
+    },
+    "json-stable-stringify-without-jsonify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+      "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=",
+      "dev": true
+    },
+    "json-stringify-safe": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
+      "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=",
+      "dev": true
+    },
+    "json5": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.0.tgz",
+      "integrity": "sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==",
+      "requires": {
+        "minimist": "^1.2.5"
+      }
+    },
+    "jsonfile": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
+      "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
+      "dev": true,
+      "requires": {
+        "graceful-fs": "^4.1.6",
+        "universalify": "^2.0.0"
+      }
+    },
+    "jsprim": {
+      "version": "1.4.2",
+      "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz",
+      "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==",
+      "dev": true,
+      "requires": {
+        "assert-plus": "1.0.0",
+        "extsprintf": "1.3.0",
+        "json-schema": "0.4.0",
+        "verror": "1.10.0"
+      }
+    },
+    "jsx-ast-utils": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.2.1.tgz",
+      "integrity": "sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==",
+      "dev": true,
+      "requires": {
+        "array-includes": "^3.1.3",
+        "object.assign": "^4.1.2"
+      }
+    },
+    "kind-of": {
+      "version": "6.0.3",
+      "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
+      "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
+      "dev": true,
+      "peer": true
+    },
+    "kleur": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz",
+      "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==",
+      "dev": true
+    },
+    "klona": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.5.tgz",
+      "integrity": "sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ==",
+      "dev": true
+    },
+    "known-css-properties": {
+      "version": "0.25.0",
+      "resolved": "https://registry.npmjs.org/known-css-properties/-/known-css-properties-0.25.0.tgz",
+      "integrity": "sha512-b0/9J1O9Jcyik1GC6KC42hJ41jKwdO/Mq8Mdo5sYN+IuRTXs2YFHZC3kZSx6ueusqa95x3wLYe/ytKjbAfGixA==",
+      "dev": true,
+      "peer": true
+    },
+    "language-subtag-registry": {
+      "version": "0.3.21",
+      "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz",
+      "integrity": "sha512-L0IqwlIXjilBVVYKFT37X9Ih11Um5NEl9cbJIuU/SwP/zEEAbBPOnEeeuxVMf45ydWQRDQN3Nqc96OgbH1K+Pg==",
+      "dev": true
+    },
+    "language-tags": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.5.tgz",
+      "integrity": "sha1-0yHbxNowuovzAk4ED6XBRmH5GTo=",
+      "dev": true,
+      "requires": {
+        "language-subtag-registry": "~0.3.2"
+      }
+    },
+    "lazy-ass": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/lazy-ass/-/lazy-ass-1.6.0.tgz",
+      "integrity": "sha1-eZllXoZGwX8In90YfRUNMyTVRRM=",
+      "dev": true
+    },
+    "leven": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
+      "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==",
+      "dev": true
+    },
+    "levn": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz",
+      "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2"
+      }
+    },
+    "lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="
+    },
+    "listr2": {
+      "version": "3.12.0",
+      "resolved": "https://registry.npmjs.org/listr2/-/listr2-3.12.0.tgz",
+      "integrity": "sha512-DLaOIhIBXxSDGfAuGyQPsQs6XPIJrUE1MaNYBq8aUS3bulSAEl9RMNNuRbfdxonTizL5ztAYvCZKKnP3gFSvYg==",
+      "dev": true,
+      "requires": {
+        "cli-truncate": "^2.1.0",
+        "colorette": "^1.2.2",
+        "log-update": "^4.0.0",
+        "p-map": "^4.0.0",
+        "rxjs": "^6.6.7",
+        "through": "^2.3.8",
+        "wrap-ansi": "^7.0.0"
+      }
+    },
+    "lit": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/lit/-/lit-2.0.2.tgz",
+      "integrity": "sha512-hKA/1YaSB+P+DvKWuR2q1Xzy/iayhNrJ3aveD0OQ9CKn6wUjsdnF/7LavDOJsKP/K5jzW/kXsuduPgRvTFrFJw==",
+      "requires": {
+        "@lit/reactive-element": "^1.0.0",
+        "lit-element": "^3.0.0",
+        "lit-html": "^2.0.0"
+      },
+      "dependencies": {
+        "@lit/reactive-element": {
+          "version": "1.0.2",
+          "resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-1.0.2.tgz",
+          "integrity": "sha512-oz3d3MKjQ2tXynQgyaQaMpGTDNyNDeBdo6dXf1AbjTwhA1IRINHmA7kSaVYv9ttKweNkEoNqp9DqteDdgWzPEg=="
+        },
+        "lit-element": {
+          "version": "3.0.2",
+          "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-3.0.2.tgz",
+          "integrity": "sha512-9vTJ47D2DSE4Jwhle7aMzEwO2ZcOPRikqfT3CVG7Qol2c9/I4KZwinZNW5Xv8hNm+G/enSSfIwqQhIXi6ioAUg==",
+          "requires": {
+            "@lit/reactive-element": "^1.0.0",
+            "lit-html": "^2.0.0"
+          }
+        },
+        "lit-html": {
+          "version": "2.0.2",
+          "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-2.0.2.tgz",
+          "integrity": "sha512-dON7Zg8btb14/fWohQLQBdSgkoiQA4mIUy87evmyJHtxRq7zS6LlC32bT5EPWiof5PUQaDpF45v2OlrxHA5Clg==",
+          "requires": {
+            "@types/trusted-types": "^2.0.2"
+          }
+        }
+      }
+    },
+    "lit-element": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmjs.org/lit-element/-/lit-element-2.5.1.tgz",
+      "integrity": "sha512-ogu7PiJTA33bEK0xGu1dmaX5vhcRjBXCFexPja0e7P7jqLhTpNKYRPmE+GmiCaRVAbiQKGkUgkh/i6+bh++dPQ==",
+      "requires": {
+        "lit-html": "^1.1.1"
+      }
+    },
+    "lit-html": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/lit-html/-/lit-html-1.4.1.tgz",
+      "integrity": "sha512-B9btcSgPYb1q4oSOb/PrOT6Z/H+r6xuNzfH4lFli/AWhYwdtrgQkQWBbIc6mdnf6E2IL3gDXdkkqNktpU0OZQA=="
+    },
+    "loader-runner": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.2.0.tgz",
+      "integrity": "sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==",
+      "dev": true,
+      "peer": true
+    },
+    "locate-path": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
+      "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
+      "dev": true,
+      "requires": {
+        "p-locate": "^4.1.0"
+      }
+    },
+    "lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "dev": true
+    },
+    "lodash.isequal": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+      "integrity": "sha1-QVxEePK8wwEgwizhDtMib30+GOA=",
+      "dev": true
+    },
+    "lodash.memoize": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz",
+      "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=",
+      "dev": true
+    },
+    "lodash.merge": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+      "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+      "dev": true
+    },
+    "lodash.once": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+      "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=",
+      "dev": true
+    },
+    "lodash.sortby": {
+      "version": "4.7.0",
+      "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+      "integrity": "sha1-7dFMgk4sycHgsKG0K7UhBRakJDg=",
+      "dev": true
+    },
+    "lodash.truncate": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/lodash.truncate/-/lodash.truncate-4.4.2.tgz",
+      "integrity": "sha1-WjUNoLERO4N+z//VgSy+WNbq4ZM=",
+      "dev": true,
+      "peer": true
+    },
+    "log-symbols": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz",
+      "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
+      "dev": true,
+      "requires": {
+        "chalk": "^4.1.0",
+        "is-unicode-supported": "^0.1.0"
+      }
+    },
+    "log-update": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/log-update/-/log-update-4.0.0.tgz",
+      "integrity": "sha512-9fkkDevMefjg0mmzWFBW8YkFP91OrizzkW3diF7CpG+S2EYdy4+TVfGwz1zeF8x7hCx1ovSPTOE9Ngib74qqUg==",
+      "dev": true,
+      "requires": {
+        "ansi-escapes": "^4.3.0",
+        "cli-cursor": "^3.1.0",
+        "slice-ansi": "^4.0.0",
+        "wrap-ansi": "^6.2.0"
+      },
+      "dependencies": {
+        "slice-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+          "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "astral-regex": "^2.0.0",
+            "is-fullwidth-code-point": "^3.0.0"
+          }
+        },
+        "wrap-ansi": {
+          "version": "6.2.0",
+          "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
+          "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
+          "dev": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "string-width": "^4.1.0",
+            "strip-ansi": "^6.0.0"
+          }
+        }
+      }
+    },
+    "loose-envify": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
+      "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
+      "requires": {
+        "js-tokens": "^3.0.0 || ^4.0.0"
+      }
+    },
+    "lru-cache": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
+      "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
+      "dev": true,
+      "requires": {
+        "yallist": "^4.0.0"
+      }
+    },
+    "luxon": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/luxon/-/luxon-2.1.1.tgz",
+      "integrity": "sha512-6VQVNw7+kQu3hL1ZH5GyOhnk8uZm21xS7XJ/6vDZaFNcb62dpFDKcH8TI5NkoZOdMRxr7af7aYGrJlE/Wv0i1w=="
+    },
+    "lz-string": {
+      "version": "1.4.4",
+      "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
+      "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
+      "dev": true
+    },
+    "make-dir": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
+      "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
+      "dev": true,
+      "requires": {
+        "semver": "^6.0.0"
+      },
+      "dependencies": {
+        "semver": {
+          "version": "6.3.0",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
+          "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==",
+          "dev": true
+        }
+      }
+    },
+    "make-error": {
+      "version": "1.3.6",
+      "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
+      "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
+      "dev": true
+    },
+    "makeerror": {
+      "version": "1.0.12",
+      "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz",
+      "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==",
+      "dev": true,
+      "requires": {
+        "tmpl": "1.0.5"
+      }
+    },
+    "map-obj": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-4.3.0.tgz",
+      "integrity": "sha512-hdN1wVrZbb29eBGiGjJbeP8JbKjq1urkHJ/LIP/NY48MZ1QVXUsQBV1G1zvYFHn1XE06cwjBsOI2K3Ulnj1YXQ==",
+      "dev": true,
+      "peer": true
+    },
+    "match-sorter": {
+      "version": "6.3.1",
+      "resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
+      "integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
+      "requires": {
+        "@babel/runtime": "^7.12.5",
+        "remove-accents": "0.4.2"
+      }
+    },
+    "mathml-tag-names": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/mathml-tag-names/-/mathml-tag-names-2.1.3.tgz",
+      "integrity": "sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==",
+      "dev": true,
+      "peer": true
+    },
+    "mdn-data": {
+      "version": "2.0.14",
+      "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
+      "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="
+    },
+    "meow": {
+      "version": "9.0.0",
+      "resolved": "https://registry.npmjs.org/meow/-/meow-9.0.0.tgz",
+      "integrity": "sha512-+obSblOQmRhcyBt62furQqRAQpNyWXo8BuQ5bN7dG8wmwQ+vwHKp/rCFD4CrTP8CsDQD1sjoZ94K417XEUk8IQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@types/minimist": "^1.2.0",
+        "camelcase-keys": "^6.2.2",
+        "decamelize": "^1.2.0",
+        "decamelize-keys": "^1.1.0",
+        "hard-rejection": "^2.1.0",
+        "minimist-options": "4.1.0",
+        "normalize-package-data": "^3.0.0",
+        "read-pkg-up": "^7.0.1",
+        "redent": "^3.0.0",
+        "trim-newlines": "^3.0.0",
+        "type-fest": "^0.18.0",
+        "yargs-parser": "^20.2.3"
+      },
+      "dependencies": {
+        "type-fest": {
+          "version": "0.18.1",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.18.1.tgz",
+          "integrity": "sha512-OIAYXk8+ISY+qTOwkHtKqzAuxchoMiD9Udx+FSGQDuiRR+PJKJHc2NJAXlbhkGwTt/4/nKZxELY1w3ReWOL8mw==",
+          "dev": true,
+          "peer": true
+        }
+      }
+    },
+    "merge-stream": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
+      "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==",
+      "dev": true
+    },
+    "merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true
+    },
+    "micromatch": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz",
+      "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==",
+      "dev": true,
+      "requires": {
+        "braces": "^3.0.2",
+        "picomatch": "^2.3.1"
+      }
+    },
+    "microseconds": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
+      "integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA=="
+    },
+    "mime-db": {
+      "version": "1.49.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.49.0.tgz",
+      "integrity": "sha512-CIc8j9URtOVApSFCQIF+VBkX1RwXp/oMMOrqdyXSBXq5RWNEsRfyj1kiRnQgmNXmHxPoFIxOroKA3zcU9P+nAA==",
+      "dev": true
+    },
+    "mime-types": {
+      "version": "2.1.32",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.32.tgz",
+      "integrity": "sha512-hJGaVS4G4c9TSMYh2n6SQAGrC4RnfU+daP8G7cSCmaqNjiOoUY0VHCMS42pxnQmVF1GWwFhbHWn3RIxCqTmZ9A==",
+      "dev": true,
+      "requires": {
+        "mime-db": "1.49.0"
+      }
+    },
+    "mimic-fn": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
+      "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
+      "dev": true
+    },
+    "min-indent": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+      "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+      "dev": true
+    },
+    "minimatch": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
+      "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+      "requires": {
+        "brace-expansion": "^1.1.7"
+      }
+    },
+    "minimist": {
+      "version": "1.2.6",
+      "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz",
+      "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q=="
+    },
+    "minimist-options": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz",
+      "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "arrify": "^1.0.1",
+        "is-plain-obj": "^1.1.0",
+        "kind-of": "^6.0.3"
+      }
+    },
+    "ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "dev": true
+    },
+    "nano-css": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/nano-css/-/nano-css-5.3.4.tgz",
+      "integrity": "sha512-wfcviJB6NOxDIDfr7RFn/GlaN7I/Bhe4d39ZRCJ3xvZX60LVe2qZ+rDqM49nm4YT81gAjzS+ZklhKP/Gnfnubg==",
+      "requires": {
+        "css-tree": "^1.1.2",
+        "csstype": "^3.0.6",
+        "fastest-stable-stringify": "^2.0.2",
+        "inline-style-prefixer": "^6.0.0",
+        "rtl-css-js": "^1.14.0",
+        "sourcemap-codec": "^1.4.8",
+        "stacktrace-js": "^2.0.2",
+        "stylis": "^4.0.6"
+      }
+    },
+    "nano-time": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
+      "integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
+      "requires": {
+        "big-integer": "^1.6.16"
+      }
+    },
+    "nanoid": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.3.tgz",
+      "integrity": "sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w=="
+    },
+    "natural-compare": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+      "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
+      "dev": true
+    },
+    "neo-async": {
+      "version": "2.6.2",
+      "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
+      "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
+      "dev": true
+    },
+    "node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "dev": true
+    },
+    "node-fetch": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.2.3.tgz",
+      "integrity": "sha512-AXP18u4pidSZ1xYXRDPY/8jdv3RAozIt/WLNR/MBGZAz+xjtlr90RvCnsvHQRiXyWliZF/CpytExp32UU67/SA==",
+      "dev": true,
+      "requires": {
+        "data-uri-to-buffer": "^4.0.0",
+        "fetch-blob": "^3.1.4",
+        "formdata-polyfill": "^4.0.10"
+      }
+    },
+    "node-int64": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
+      "integrity": "sha1-h6kGXNs1XTGC2PlM4RGIuCXGijs=",
+      "dev": true
+    },
+    "node-releases": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.1.tgz",
+      "integrity": "sha512-CqyzN6z7Q6aMeF/ktcMVTzhAHCEpf8SOarwpzpf8pNBY2k5/oM34UHldUwp8VKI7uxct2HxSRdJjBaZeESzcxA=="
+    },
+    "normalize-package-data": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz",
+      "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "hosted-git-info": "^4.0.1",
+        "is-core-module": "^2.5.0",
+        "semver": "^7.3.4",
+        "validate-npm-package-license": "^3.0.1"
+      }
+    },
+    "normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="
+    },
+    "normalize-selector": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/normalize-selector/-/normalize-selector-0.2.0.tgz",
+      "integrity": "sha1-0LFF62kRicY6eNIB3E/bEpPvDAM=",
+      "dev": true,
+      "peer": true
+    },
+    "npm-run-path": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz",
+      "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==",
+      "dev": true,
+      "requires": {
+        "path-key": "^3.0.0"
+      }
+    },
+    "nwsapi": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz",
+      "integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==",
+      "dev": true
+    },
+    "object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+    },
+    "object-inspect": {
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.0.tgz",
+      "integrity": "sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==",
+      "dev": true
+    },
+    "object-keys": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
+      "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
+      "dev": true
+    },
+    "object.assign": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.2.tgz",
+      "integrity": "sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "define-properties": "^1.1.3",
+        "has-symbols": "^1.0.1",
+        "object-keys": "^1.1.1"
+      }
+    },
+    "object.entries": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.5.tgz",
+      "integrity": "sha512-TyxmjUoZggd4OrrU1W66FMDG6CuqJxsFvymeyXI51+vQLN67zYfZseptRge703kKQdo4uccgAKebXFcRCzk4+g==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      }
+    },
+    "object.fromentries": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.5.tgz",
+      "integrity": "sha512-CAyG5mWQRRiBU57Re4FKoTBjXfDoNwdFVH2Y1tS9PqCsfUTymAohOkEMSG3aRNKmv4lV3O7p1et7c187q6bynw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      }
+    },
+    "object.hasown": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.0.tgz",
+      "integrity": "sha512-MhjYRfj3GBlhSkDHo6QmvgjRLXQ2zndabdf3nX0yTyZK9rPfxb6uRpAac8HXNLy1GpqWtZ81Qh4v3uOls2sRAg==",
+      "dev": true,
+      "requires": {
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      }
+    },
+    "object.values": {
+      "version": "1.1.5",
+      "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.5.tgz",
+      "integrity": "sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1"
+      }
+    },
+    "oblivious-set": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
+      "integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw=="
+    },
+    "once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+      "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=",
+      "requires": {
+        "wrappy": "1"
+      }
+    },
+    "onetime": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz",
+      "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
+      "dev": true,
+      "requires": {
+        "mimic-fn": "^2.1.0"
+      }
+    },
+    "optionator": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz",
+      "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==",
+      "dev": true,
+      "requires": {
+        "deep-is": "~0.1.3",
+        "fast-levenshtein": "~2.0.6",
+        "levn": "~0.3.0",
+        "prelude-ls": "~1.1.2",
+        "type-check": "~0.3.2",
+        "word-wrap": "~1.2.3"
+      }
+    },
+    "ospath": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz",
+      "integrity": "sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=",
+      "dev": true
+    },
+    "p-limit": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+      "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "yocto-queue": "^0.1.0"
+      }
+    },
+    "p-locate": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
+      "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
+      "dev": true,
+      "requires": {
+        "p-limit": "^2.2.0"
+      },
+      "dependencies": {
+        "p-limit": {
+          "version": "2.3.0",
+          "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
+          "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
+          "dev": true,
+          "requires": {
+            "p-try": "^2.0.0"
+          }
+        }
+      }
+    },
+    "p-map": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz",
+      "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==",
+      "dev": true,
+      "requires": {
+        "aggregate-error": "^3.0.0"
+      }
+    },
+    "p-try": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
+      "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
+      "dev": true
+    },
+    "parent-module": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+      "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+      "requires": {
+        "callsites": "^3.0.0"
+      }
+    },
+    "parse-json": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+      "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+      "requires": {
+        "@babel/code-frame": "^7.0.0",
+        "error-ex": "^1.3.1",
+        "json-parse-even-better-errors": "^2.3.0",
+        "lines-and-columns": "^1.1.6"
+      }
+    },
+    "parse5": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
+      "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==",
+      "dev": true
+    },
+    "path-exists": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+      "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+      "dev": true
+    },
+    "path-is-absolute": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
+      "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
+    },
+    "path-key": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+      "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+      "dev": true
+    },
+    "path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
+    },
+    "path-to-regexp": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz",
+      "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w=="
+    },
+    "path-type": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
+      "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="
+    },
+    "pend": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+      "integrity": "sha1-elfrVQpng/kRUzH89GY9XI4AelA=",
+      "dev": true
+    },
+    "performance-now": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
+      "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=",
+      "dev": true
+    },
+    "picocolors": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
+      "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
+    },
+    "picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="
+    },
+    "pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=",
+      "dev": true
+    },
+    "pirates": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.5.tgz",
+      "integrity": "sha512-8V9+HQPupnaXMA23c5hvl69zXvTwTzyAYasnkb0Tts4XvO4CliqONMOnvlq26rkhLC3nWDFBJf73LU1e1VZLaQ==",
+      "dev": true
+    },
+    "pkg-dir": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz",
+      "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==",
+      "dev": true,
+      "requires": {
+        "find-up": "^4.0.0"
+      }
+    },
+    "postcss": {
+      "version": "8.4.13",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.13.tgz",
+      "integrity": "sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA==",
+      "dev": true,
+      "requires": {
+        "nanoid": "^3.3.3",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      }
+    },
+    "postcss-media-query-parser": {
+      "version": "0.2.3",
+      "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz",
+      "integrity": "sha1-J7Ocb02U+Bsac7j3Y1HGCeXO8kQ=",
+      "dev": true
+    },
+    "postcss-modules-extract-imports": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz",
+      "integrity": "sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==",
+      "dev": true,
+      "requires": {}
+    },
+    "postcss-modules-local-by-default": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz",
+      "integrity": "sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ==",
+      "dev": true,
+      "requires": {
+        "icss-utils": "^5.0.0",
+        "postcss-selector-parser": "^6.0.2",
+        "postcss-value-parser": "^4.1.0"
+      }
+    },
+    "postcss-modules-scope": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz",
+      "integrity": "sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==",
+      "dev": true,
+      "requires": {
+        "postcss-selector-parser": "^6.0.4"
+      }
+    },
+    "postcss-modules-values": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz",
+      "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==",
+      "dev": true,
+      "requires": {
+        "icss-utils": "^5.0.0"
+      }
+    },
+    "postcss-resolve-nested-selector": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.1.tgz",
+      "integrity": "sha1-Kcy8fDfe36wwTp//C/FZaz9qDk4=",
+      "dev": true
+    },
+    "postcss-safe-parser": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-6.0.0.tgz",
+      "integrity": "sha512-FARHN8pwH+WiS2OPCxJI8FuRJpTVnn6ZNFiqAM2aeW2LwTHWWmWgIyKC6cUo0L8aeKiF/14MNvnpls6R2PBeMQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {}
+    },
+    "postcss-selector-parser": {
+      "version": "6.0.10",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
+      "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
+      "dev": true,
+      "requires": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      }
+    },
+    "postcss-sorting": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-sorting/-/postcss-sorting-7.0.1.tgz",
+      "integrity": "sha512-iLBFYz6VRYyLJEJsBJ8M3TCqNcckVzz4wFounSc5Oez35ogE/X+aoC5fFu103Ot7NyvjU3/xqIXn93Gp3kJk4g==",
+      "dev": true,
+      "requires": {}
+    },
+    "postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true
+    },
+    "prelude-ls": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz",
+      "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=",
+      "dev": true
+    },
+    "prettier": {
+      "version": "2.6.1",
+      "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.6.1.tgz",
+      "integrity": "sha512-8UVbTBYGwN37Bs9LERmxCPjdvPxlEowx2urIL6urHzdb3SDq4B/Z6xLFCblrSnE4iKWcS6ziJ3aOYrc1kz/E2A==",
+      "dev": true,
+      "peer": true
+    },
+    "prettier-linter-helpers": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.0.tgz",
+      "integrity": "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==",
+      "dev": true,
+      "requires": {
+        "fast-diff": "^1.1.2"
+      }
+    },
+    "pretty-bytes": {
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+      "integrity": "sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg==",
+      "dev": true
+    },
+    "pretty-format": {
+      "version": "27.5.1",
+      "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+      "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.1",
+        "ansi-styles": "^5.0.0",
+        "react-is": "^17.0.1"
+      },
+      "dependencies": {
+        "ansi-styles": {
+          "version": "5.2.0",
+          "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+          "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+          "dev": true
+        }
+      }
+    },
+    "prompts": {
+      "version": "2.4.2",
+      "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz",
+      "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==",
+      "dev": true,
+      "requires": {
+        "kleur": "^3.0.3",
+        "sisteransi": "^1.0.5"
+      }
+    },
+    "prop-types": {
+      "version": "15.8.1",
+      "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
+      "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
+      "requires": {
+        "loose-envify": "^1.4.0",
+        "object-assign": "^4.1.1",
+        "react-is": "^16.13.1"
+      },
+      "dependencies": {
+        "react-is": {
+          "version": "16.13.1",
+          "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
+          "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
+        }
+      }
+    },
+    "psl": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz",
+      "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
+      "dev": true
+    },
+    "pump": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
+      "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==",
+      "dev": true,
+      "requires": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "punycode": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
+      "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
+      "dev": true
+    },
+    "qs": {
+      "version": "6.5.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz",
+      "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==",
+      "dev": true
+    },
+    "querystring": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz",
+      "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=",
+      "dev": true
+    },
+    "queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true
+    },
+    "quick-lru": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-4.0.1.tgz",
+      "integrity": "sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g==",
+      "dev": true,
+      "peer": true
+    },
+    "ramda": {
+      "version": "0.27.1",
+      "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz",
+      "integrity": "sha512-PgIdVpn5y5Yns8vqb8FzBUEYn98V3xcPgawAkkgj0YJ0qDsnHCiNmZYfOGMgOvoB0eWFLpYbhxUR3mxfDIMvpw==",
+      "dev": true
+    },
+    "randombytes": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
+      "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "safe-buffer": "^5.1.0"
+      }
+    },
+    "react": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react/-/react-17.0.2.tgz",
+      "integrity": "sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==",
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      }
+    },
+    "react-chartjs-2": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-4.0.1.tgz",
+      "integrity": "sha512-q8bgWzKoFvBvD7YcjT/hXG8jt55TaMAuJ1dmI3tKFJ7CijUWYz4pIfOhkTI6PBTwqu/pmeWsClBRd/7HiWzN1g==",
+      "requires": {}
+    },
+    "react-dom": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz",
+      "integrity": "sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==",
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1",
+        "scheduler": "^0.20.2"
+      }
+    },
+    "react-error-boundary": {
+      "version": "3.1.4",
+      "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+      "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+      "dev": true,
+      "requires": {
+        "@babel/runtime": "^7.12.5"
+      }
+    },
+    "react-is": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+      "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+      "dev": true
+    },
+    "react-query": {
+      "version": "3.34.16",
+      "resolved": "https://registry.npmjs.org/react-query/-/react-query-3.34.16.tgz",
+      "integrity": "sha512-7FvBvjgEM4YQ8nPfmAr+lJfbW95uyW/TVjFoi2GwCkF33/S8ajx45tuPHPFGWs4qYwPy1mzwxD4IQfpUDrefNQ==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "broadcast-channel": "^3.4.1",
+        "match-sorter": "^6.0.2"
+      }
+    },
+    "react-router": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.2.2.tgz",
+      "integrity": "sha512-/MbxyLzd7Q7amp4gDOGaYvXwhEojkJD5BtExkuKmj39VEE0m3l/zipf6h2WIB2jyAO0lI6NGETh4RDcktRm4AQ==",
+      "requires": {
+        "history": "^5.2.0"
+      }
+    },
+    "react-router-dom": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.2.2.tgz",
+      "integrity": "sha512-AtYEsAST7bDD4dLSQHDnk/qxWLJdad5t1HFa1qJyUrCeGgEuCSw0VB/27ARbF9Fi/W5598ujvJOm3ujUCVzuYQ==",
+      "requires": {
+        "history": "^5.2.0",
+        "react-router": "6.2.2"
+      }
+    },
+    "react-shallow-renderer": {
+      "version": "16.14.1",
+      "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.14.1.tgz",
+      "integrity": "sha512-rkIMcQi01/+kxiTE9D3fdS959U1g7gs+/rborw++42m1O9FAQiNI/UNRZExVUoAOprn4umcXf+pFRou8i4zuBg==",
+      "dev": true,
+      "requires": {
+        "object-assign": "^4.1.1",
+        "react-is": "^16.12.0 || ^17.0.0"
+      }
+    },
+    "react-test-renderer": {
+      "version": "17.0.2",
+      "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-17.0.2.tgz",
+      "integrity": "sha512-yaQ9cB89c17PUb0x6UfWRs7kQCorVdHlutU1boVPEsB8IDZH6n9tHxMacc3y0JoXOJUsZb/t/Mb8FUWMKaM7iQ==",
+      "dev": true,
+      "requires": {
+        "object-assign": "^4.1.1",
+        "react-is": "^17.0.2",
+        "react-shallow-renderer": "^16.13.1",
+        "scheduler": "^0.20.2"
+      }
+    },
+    "react-transition-group": {
+      "version": "4.4.2",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.2.tgz",
+      "integrity": "sha512-/RNYfRAMlZwDSr6z4zNKV6xu53/e2BuaBbGhbyYIXTrmgu/bGHzmqOs7mJSJBHy9Ud+ApHx3QjrkKSp1pxvlFg==",
+      "requires": {
+        "@babel/runtime": "^7.5.5",
+        "dom-helpers": "^5.0.1",
+        "loose-envify": "^1.4.0",
+        "prop-types": "^15.6.2"
+      }
+    },
+    "react-universal-interface": {
+      "version": "0.6.2",
+      "resolved": "https://registry.npmjs.org/react-universal-interface/-/react-universal-interface-0.6.2.tgz",
+      "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==",
+      "requires": {}
+    },
+    "react-use": {
+      "version": "17.3.2",
+      "resolved": "https://registry.npmjs.org/react-use/-/react-use-17.3.2.tgz",
+      "integrity": "sha512-bj7OD0/1wL03KyWmzFXAFe425zziuTf7q8olwCYBfOeFHY1qfO1FAMjROQLsLZYwG4Rx63xAfb7XAbBrJsZmEw==",
+      "requires": {
+        "@types/js-cookie": "^2.2.6",
+        "@xobotyi/scrollbar-width": "^1.9.5",
+        "copy-to-clipboard": "^3.3.1",
+        "fast-deep-equal": "^3.1.3",
+        "fast-shallow-equal": "^1.0.0",
+        "js-cookie": "^2.2.1",
+        "nano-css": "^5.3.1",
+        "react-universal-interface": "^0.6.2",
+        "resize-observer-polyfill": "^1.5.1",
+        "screenfull": "^5.1.0",
+        "set-harmonic-interval": "^1.0.1",
+        "throttle-debounce": "^3.0.1",
+        "ts-easing": "^0.2.0",
+        "tslib": "^2.1.0"
+      },
+      "dependencies": {
+        "tslib": {
+          "version": "2.3.1",
+          "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
+          "integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
+        }
+      }
+    },
+    "read-pkg": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz",
+      "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@types/normalize-package-data": "^2.4.0",
+        "normalize-package-data": "^2.5.0",
+        "parse-json": "^5.0.0",
+        "type-fest": "^0.6.0"
+      },
+      "dependencies": {
+        "hosted-git-info": {
+          "version": "2.8.9",
+          "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz",
+          "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==",
+          "dev": true,
+          "peer": true
+        },
+        "normalize-package-data": {
+          "version": "2.5.0",
+          "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz",
+          "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==",
+          "dev": true,
+          "peer": true,
+          "requires": {
+            "hosted-git-info": "^2.1.4",
+            "resolve": "^1.10.0",
+            "semver": "2 || 3 || 4 || 5",
+            "validate-npm-package-license": "^3.0.1"
+          }
+        },
+        "semver": {
+          "version": "5.7.1",
+          "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",
+          "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==",
+          "dev": true,
+          "peer": true
+        },
+        "type-fest": {
+          "version": "0.6.0",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz",
+          "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==",
+          "dev": true,
+          "peer": true
+        }
+      }
+    },
+    "read-pkg-up": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz",
+      "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "find-up": "^4.1.0",
+        "read-pkg": "^5.2.0",
+        "type-fest": "^0.8.1"
+      },
+      "dependencies": {
+        "type-fest": {
+          "version": "0.8.1",
+          "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz",
+          "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==",
+          "dev": true,
+          "peer": true
+        }
+      }
+    },
+    "readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "requires": {
+        "picomatch": "^2.2.1"
+      }
+    },
+    "redent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+      "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+      "dev": true,
+      "requires": {
+        "indent-string": "^4.0.0",
+        "strip-indent": "^3.0.0"
+      }
+    },
+    "regenerator-runtime": {
+      "version": "0.13.9",
+      "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz",
+      "integrity": "sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA=="
+    },
+    "regexp.prototype.flags": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.4.1.tgz",
+      "integrity": "sha512-pMR7hBVUUGI7PMA37m2ofIdQCsomVnas+Jn5UPGAHQ+/LlwKm/aTLJHdasmHRzlfeZwHiAOaRSo2rbBDm3nNUQ==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      }
+    },
+    "regexpp": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-3.2.0.tgz",
+      "integrity": "sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==",
+      "dev": true
+    },
+    "remove-accents": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
+      "integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U="
+    },
+    "request-progress": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/request-progress/-/request-progress-3.0.0.tgz",
+      "integrity": "sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=",
+      "dev": true,
+      "requires": {
+        "throttleit": "^1.0.0"
+      }
+    },
+    "require-directory": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
+      "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=",
+      "dev": true
+    },
+    "require-from-string": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+      "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+      "dev": true,
+      "peer": true
+    },
+    "resize-observer-polyfill": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
+      "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
+    },
+    "resolve": {
+      "version": "1.22.0",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.0.tgz",
+      "integrity": "sha512-Hhtrw0nLeSrFQ7phPp4OOcVjLPIeMnRlr5mcnVuMe7M/7eBn98A3hmFRLoFo3DLZkivSYwhRUJTyPyWAk56WLw==",
+      "requires": {
+        "is-core-module": "^2.8.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      }
+    },
+    "resolve-cwd": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
+      "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==",
+      "dev": true,
+      "requires": {
+        "resolve-from": "^5.0.0"
+      },
+      "dependencies": {
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true
+        }
+      }
+    },
+    "resolve-from": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+      "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="
+    },
+    "resolve.exports": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-1.1.0.tgz",
+      "integrity": "sha512-J1l+Zxxp4XK3LUDZ9m60LRJF/mAe4z6a4xyabPHk7pvK5t35dACV32iIjJDFeWZFfZlO29w6SZ67knR0tHzJtQ==",
+      "dev": true
+    },
+    "restore-cursor": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz",
+      "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
+      "dev": true,
+      "requires": {
+        "onetime": "^5.1.0",
+        "signal-exit": "^3.0.2"
+      }
+    },
+    "reusify": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
+      "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
+      "dev": true
+    },
+    "rimraf": {
+      "version": "3.0.2",
+      "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
+      "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
+      "requires": {
+        "glob": "^7.1.3"
+      }
+    },
+    "rtl-css-js": {
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/rtl-css-js/-/rtl-css-js-1.15.0.tgz",
+      "integrity": "sha512-99Cu4wNNIhrI10xxUaABHsdDqzalrSRTie4GeCmbGVuehm4oj+fIy8fTzB+16pmKe8Bv9rl+hxIBez6KxExTew==",
+      "requires": {
+        "@babel/runtime": "^7.1.2"
+      }
+    },
+    "run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "requires": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "rxjs": {
+      "version": "6.6.7",
+      "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-6.6.7.tgz",
+      "integrity": "sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==",
+      "dev": true,
+      "requires": {
+        "tslib": "^1.9.0"
+      }
+    },
+    "safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "dev": true
+    },
+    "safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "dev": true
+    },
+    "sass": {
+      "version": "1.49.7",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.49.7.tgz",
+      "integrity": "sha512-13dml55EMIR2rS4d/RDHHP0sXMY3+30e1TKsyXaSz3iLWVoDWEoboY8WzJd5JMnxrRHffKO3wq2mpJ0jxRJiEQ==",
+      "requires": {
+        "chokidar": ">=3.0.0 <4.0.0",
+        "immutable": "^4.0.0",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      }
+    },
+    "sass-loader": {
+      "version": "12.6.0",
+      "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz",
+      "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==",
+      "dev": true,
+      "requires": {
+        "klona": "^2.0.4",
+        "neo-async": "^2.6.2"
+      }
+    },
+    "saxes": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz",
+      "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==",
+      "dev": true,
+      "requires": {
+        "xmlchars": "^2.2.0"
+      }
+    },
+    "scheduler": {
+      "version": "0.20.2",
+      "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.20.2.tgz",
+      "integrity": "sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==",
+      "requires": {
+        "loose-envify": "^1.1.0",
+        "object-assign": "^4.1.1"
+      }
+    },
+    "schema-utils": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.1.1.tgz",
+      "integrity": "sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@types/json-schema": "^7.0.8",
+        "ajv": "^6.12.5",
+        "ajv-keywords": "^3.5.2"
+      }
+    },
+    "screenfull": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/screenfull/-/screenfull-5.2.0.tgz",
+      "integrity": "sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA=="
+    },
+    "semver": {
+      "version": "7.3.5",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.5.tgz",
+      "integrity": "sha512-PoeGJYh8HK4BTO/a9Tf6ZG3veo/A7ZVsYrSA6J8ny9nb3B1VrpkuN+z9OE5wfE5p6H4LchYZsegiQgbJD94ZFQ==",
+      "dev": true,
+      "requires": {
+        "lru-cache": "^6.0.0"
+      }
+    },
+    "serialize-javascript": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.0.tgz",
+      "integrity": "sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "randombytes": "^2.1.0"
+      }
+    },
+    "set-harmonic-interval": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/set-harmonic-interval/-/set-harmonic-interval-1.0.1.tgz",
+      "integrity": "sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g=="
+    },
+    "shebang-command": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+      "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+      "dev": true,
+      "requires": {
+        "shebang-regex": "^3.0.0"
+      }
+    },
+    "shebang-regex": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+      "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+      "dev": true
+    },
+    "side-channel": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz",
+      "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.0",
+        "get-intrinsic": "^1.0.2",
+        "object-inspect": "^1.9.0"
+      }
+    },
+    "signal-exit": {
+      "version": "3.0.7",
+      "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
+      "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
+      "dev": true
+    },
+    "sisteransi": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
+      "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
+      "dev": true
+    },
+    "slash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
+      "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==",
+      "dev": true
+    },
+    "slice-ansi": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-3.0.0.tgz",
+      "integrity": "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "astral-regex": "^2.0.0",
+        "is-fullwidth-code-point": "^3.0.0"
+      }
+    },
+    "source-map": {
+      "version": "0.6.1",
+      "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+      "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
+    },
+    "source-map-js": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
+      "integrity": "sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw=="
+    },
+    "source-map-resolve": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.6.0.tgz",
+      "integrity": "sha512-KXBr9d/fO/bWo97NXsPIAW1bFSBOuCnjbNTBMO7N59hsv5i9yzRDfcYwwt0l04+VqnKC+EwzvJZIP/qkuMgR/w==",
+      "dev": true,
+      "requires": {
+        "atob": "^2.1.2",
+        "decode-uri-component": "^0.2.0"
+      }
+    },
+    "source-map-support": {
+      "version": "0.5.21",
+      "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
+      "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
+      "dev": true,
+      "requires": {
+        "buffer-from": "^1.0.0",
+        "source-map": "^0.6.0"
+      }
+    },
+    "sourcemap-codec": {
+      "version": "1.4.8",
+      "resolved": "https://registry.npmjs.org/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz",
+      "integrity": "sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA=="
+    },
+    "spdx-correct": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.1.1.tgz",
+      "integrity": "sha512-cOYcUWwhCuHCXi49RhFRCyJEK3iPj1Ziz9DpViV3tbZOwXD49QzIN3MpOLJNxh2qwq2lJJZaKMVw9qNi4jTC0w==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "spdx-expression-parse": "^3.0.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "spdx-exceptions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz",
+      "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==",
+      "dev": true,
+      "peer": true
+    },
+    "spdx-expression-parse": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz",
+      "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "spdx-exceptions": "^2.1.0",
+        "spdx-license-ids": "^3.0.0"
+      }
+    },
+    "spdx-license-ids": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.11.tgz",
+      "integrity": "sha512-Ctl2BrFiM0X3MANYgj3CkygxhRmr9mi6xhejbdO960nF6EDJApTYpn0BQnDKlnNBULKiCN1n3w9EBkHK8ZWg+g==",
+      "dev": true,
+      "peer": true
+    },
+    "specificity": {
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/specificity/-/specificity-0.4.1.tgz",
+      "integrity": "sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg==",
+      "dev": true,
+      "peer": true
+    },
+    "sprintf-js": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
+      "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=",
+      "dev": true
+    },
+    "sshpk": {
+      "version": "1.16.1",
+      "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.16.1.tgz",
+      "integrity": "sha512-HXXqVUq7+pcKeLqqZj6mHFUMvXtOJt1uoUx09pFW6011inTMxqI8BA8PM95myrIyyKwdnzjdFjLiE6KBPVtJIg==",
+      "dev": true,
+      "requires": {
+        "asn1": "~0.2.3",
+        "assert-plus": "^1.0.0",
+        "bcrypt-pbkdf": "^1.0.0",
+        "dashdash": "^1.12.0",
+        "ecc-jsbn": "~0.1.1",
+        "getpass": "^0.1.1",
+        "jsbn": "~0.1.0",
+        "safer-buffer": "^2.0.2",
+        "tweetnacl": "~0.14.0"
+      }
+    },
+    "stack-generator": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.5.tgz",
+      "integrity": "sha512-/t1ebrbHkrLrDuNMdeAcsvynWgoH/i4o8EGGfX7dEYDoTXOYVAkEpFdtshlvabzc6JlJ8Kf9YdFEoz7JkzGN9Q==",
+      "requires": {
+        "stackframe": "^1.1.1"
+      }
+    },
+    "stack-utils": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.5.tgz",
+      "integrity": "sha512-xrQcmYhOsn/1kX+Vraq+7j4oE2j/6BFscZ0etmYg81xuM8Gq0022Pxb8+IqgOFUIaxHs0KaSb7T1+OegiNrNFA==",
+      "dev": true,
+      "requires": {
+        "escape-string-regexp": "^2.0.0"
+      },
+      "dependencies": {
+        "escape-string-regexp": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+          "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+          "dev": true
+        }
+      }
+    },
+    "stackframe": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.2.1.tgz",
+      "integrity": "sha512-h88QkzREN/hy8eRdyNhhsO7RSJ5oyTqxxmmn0dzBIMUclZsjpfmrsg81vp8mjjAs2vAZ72nyWxRUwSwmh0e4xg=="
+    },
+    "stacktrace-gps": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/stacktrace-gps/-/stacktrace-gps-3.0.4.tgz",
+      "integrity": "sha512-qIr8x41yZVSldqdqe6jciXEaSCKw1U8XTXpjDuy0ki/apyTn/r3w9hDAAQOhZdxvsC93H+WwwEu5cq5VemzYeg==",
+      "requires": {
+        "source-map": "0.5.6",
+        "stackframe": "^1.1.1"
+      },
+      "dependencies": {
+        "source-map": {
+          "version": "0.5.6",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.6.tgz",
+          "integrity": "sha1-dc449SvwczxafwwRjYEzSiu19BI="
+        }
+      }
+    },
+    "stacktrace-js": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/stacktrace-js/-/stacktrace-js-2.0.2.tgz",
+      "integrity": "sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==",
+      "requires": {
+        "error-stack-parser": "^2.0.6",
+        "stack-generator": "^2.0.5",
+        "stacktrace-gps": "^3.0.4"
+      }
+    },
+    "string-length": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
+      "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==",
+      "dev": true,
+      "requires": {
+        "char-regex": "^1.0.2",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "string-width": {
+      "version": "4.2.3",
+      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+      "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+      "dev": true,
+      "requires": {
+        "emoji-regex": "^8.0.0",
+        "is-fullwidth-code-point": "^3.0.0",
+        "strip-ansi": "^6.0.1"
+      }
+    },
+    "string.prototype.matchall": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.7.tgz",
+      "integrity": "sha512-f48okCX7JiwVi1NXCVWcFnZgADDC/n2vePlQ/KUCNqCikLLilQvwjMO8+BHVKvgzH0JB0J9LEPgxOGT02RoETg==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3",
+        "es-abstract": "^1.19.1",
+        "get-intrinsic": "^1.1.1",
+        "has-symbols": "^1.0.3",
+        "internal-slot": "^1.0.3",
+        "regexp.prototype.flags": "^1.4.1",
+        "side-channel": "^1.0.4"
+      }
+    },
+    "string.prototype.trimend": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.4.tgz",
+      "integrity": "sha512-y9xCjw1P23Awk8EvTpcyL2NIr1j7wJ39f+k6lvRnSMz+mz9CGz9NYPelDk42kOz6+ql8xjfK8oYzy3jAP5QU5A==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      }
+    },
+    "string.prototype.trimstart": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.4.tgz",
+      "integrity": "sha512-jh6e984OBfvxS50tdY2nRZnoC5/mLFKOREQfw8t5yytkoUsJRNxvI/E39qu1sD0OtWI3OC0XgKSmcWwziwYuZw==",
+      "dev": true,
+      "requires": {
+        "call-bind": "^1.0.2",
+        "define-properties": "^1.1.3"
+      }
+    },
+    "strip-ansi": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+      "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+      "dev": true,
+      "requires": {
+        "ansi-regex": "^5.0.1"
+      }
+    },
+    "strip-bom": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz",
+      "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==",
+      "dev": true
+    },
+    "strip-final-newline": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz",
+      "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
+      "dev": true
+    },
+    "strip-indent": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+      "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+      "dev": true,
+      "requires": {
+        "min-indent": "^1.0.0"
+      }
+    },
+    "strip-json-comments": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+      "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+      "dev": true
+    },
+    "style-loader": {
+      "version": "3.2.1",
+      "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.2.1.tgz",
+      "integrity": "sha512-1k9ZosJCRFaRbY6hH49JFlRB0fVSbmnyq1iTPjNxUmGVjBNEmwrrHPenhlp+Lgo51BojHSf6pl2FcqYaN3PfVg==",
+      "dev": true,
+      "requires": {}
+    },
+    "style-search": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/style-search/-/style-search-0.1.0.tgz",
+      "integrity": "sha1-eVjHk+R+MuB9K1yv5cC/jhLneQI=",
+      "dev": true,
+      "peer": true
+    },
+    "stylelint": {
+      "version": "14.8.2",
+      "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-14.8.2.tgz",
+      "integrity": "sha512-tjDfexCYfoPdl/xcDJ9Fv+Ko9cvzbDnmdiaqEn3ovXHXasi/hbkt5tSjsiReQ+ENqnz0eltaX/AOO+AlzVdcNA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "balanced-match": "^2.0.0",
+        "colord": "^2.9.2",
+        "cosmiconfig": "^7.0.1",
+        "css-functions-list": "^3.0.1",
+        "debug": "^4.3.4",
+        "execall": "^2.0.0",
+        "fast-glob": "^3.2.11",
+        "fastest-levenshtein": "^1.0.12",
+        "file-entry-cache": "^6.0.1",
+        "get-stdin": "^8.0.0",
+        "global-modules": "^2.0.0",
+        "globby": "^11.1.0",
+        "globjoin": "^0.1.4",
+        "html-tags": "^3.2.0",
+        "ignore": "^5.2.0",
+        "import-lazy": "^4.0.0",
+        "imurmurhash": "^0.1.4",
+        "is-plain-object": "^5.0.0",
+        "known-css-properties": "^0.25.0",
+        "mathml-tag-names": "^2.1.3",
+        "meow": "^9.0.0",
+        "micromatch": "^4.0.5",
+        "normalize-path": "^3.0.0",
+        "normalize-selector": "^0.2.0",
+        "picocolors": "^1.0.0",
+        "postcss": "^8.4.13",
+        "postcss-media-query-parser": "^0.2.3",
+        "postcss-resolve-nested-selector": "^0.1.1",
+        "postcss-safe-parser": "^6.0.0",
+        "postcss-selector-parser": "^6.0.10",
+        "postcss-value-parser": "^4.2.0",
+        "resolve-from": "^5.0.0",
+        "specificity": "^0.4.1",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1",
+        "style-search": "^0.1.0",
+        "supports-hyperlinks": "^2.2.0",
+        "svg-tags": "^1.0.0",
+        "table": "^6.8.0",
+        "v8-compile-cache": "^2.3.0",
+        "write-file-atomic": "^4.0.1"
+      },
+      "dependencies": {
+        "balanced-match": {
+          "version": "2.0.0",
+          "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-2.0.0.tgz",
+          "integrity": "sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==",
+          "dev": true,
+          "peer": true
+        },
+        "cosmiconfig": {
+          "version": "7.0.1",
+          "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.0.1.tgz",
+          "integrity": "sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ==",
+          "dev": true,
+          "peer": true,
+          "requires": {
+            "@types/parse-json": "^4.0.0",
+            "import-fresh": "^3.2.1",
+            "parse-json": "^5.0.0",
+            "path-type": "^4.0.0",
+            "yaml": "^1.10.0"
+          }
+        },
+        "resolve-from": {
+          "version": "5.0.0",
+          "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
+          "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
+          "dev": true,
+          "peer": true
+        },
+        "write-file-atomic": {
+          "version": "4.0.1",
+          "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.1.tgz",
+          "integrity": "sha512-nSKUxgAbyioruk6hU87QzVbY279oYT6uiwgDoujth2ju4mJ+TZau7SQBhtbTmUyuNYTuXnSyRn66FV0+eCgcrQ==",
+          "dev": true,
+          "peer": true,
+          "requires": {
+            "imurmurhash": "^0.1.4",
+            "signal-exit": "^3.0.7"
+          }
+        }
+      }
+    },
+    "stylelint-config-css-modules": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-css-modules/-/stylelint-config-css-modules-4.1.0.tgz",
+      "integrity": "sha512-w6d552NscwvpUEaUcmq8GgWXKRv6lVHLbDj6QIHSM2vCWr83qRqRvXBJCfXDyaG/J3Zojw2inU9VvU99ZlXuUw==",
+      "dev": true,
+      "requires": {
+        "stylelint-scss": "^4.2.0"
+      }
+    },
+    "stylelint-config-recess-order": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-recess-order/-/stylelint-config-recess-order-3.0.0.tgz",
+      "integrity": "sha512-uNXrlDz570Q7HJlrq8mNjgfO/xlKIh2hKVKEFMTG1/ih/6tDLcTbuvO1Zoo2dnQay990OAkWLDpTDOorB+hmBw==",
+      "dev": true,
+      "requires": {
+        "stylelint-order": "5.x"
+      }
+    },
+    "stylelint-config-recommended": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-recommended/-/stylelint-config-recommended-7.0.0.tgz",
+      "integrity": "sha512-yGn84Bf/q41J4luis1AZ95gj0EQwRX8lWmGmBwkwBNSkpGSpl66XcPTulxGa/Z91aPoNGuIGBmFkcM1MejMo9Q==",
+      "dev": true,
+      "requires": {}
+    },
+    "stylelint-config-standard": {
+      "version": "25.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-config-standard/-/stylelint-config-standard-25.0.0.tgz",
+      "integrity": "sha512-21HnP3VSpaT1wFjFvv9VjvOGDtAviv47uTp3uFmzcN+3Lt+RYRv6oAplLaV51Kf792JSxJ6svCJh/G18E9VnCA==",
+      "dev": true,
+      "requires": {
+        "stylelint-config-recommended": "^7.0.0"
+      }
+    },
+    "stylelint-order": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/stylelint-order/-/stylelint-order-5.0.0.tgz",
+      "integrity": "sha512-OWQ7pmicXufDw5BlRqzdz3fkGKJPgLyDwD1rFY3AIEfIH/LQY38Vu/85v8/up0I+VPiuGRwbc2Hg3zLAsJaiyw==",
+      "dev": true,
+      "requires": {
+        "postcss": "^8.3.11",
+        "postcss-sorting": "^7.0.1"
+      }
+    },
+    "stylelint-scss": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/stylelint-scss/-/stylelint-scss-4.2.0.tgz",
+      "integrity": "sha512-HHHMVKJJ5RM9pPIbgJ/XA67h9H0407G68Rm69H4fzFbFkyDMcTV1Byep3qdze5+fJ3c0U7mJrbj6S0Fg072uZA==",
+      "dev": true,
+      "requires": {
+        "lodash": "^4.17.21",
+        "postcss-media-query-parser": "^0.2.3",
+        "postcss-resolve-nested-selector": "^0.1.1",
+        "postcss-selector-parser": "^6.0.6",
+        "postcss-value-parser": "^4.1.0"
+      }
+    },
+    "stylis": {
+      "version": "4.0.13",
+      "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.0.13.tgz",
+      "integrity": "sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag=="
+    },
+    "supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^4.0.0"
+      }
+    },
+    "supports-hyperlinks": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.2.0.tgz",
+      "integrity": "sha512-6sXEzV5+I5j8Bmq9/vUphGRM/RJNT9SCURJLjwfOg51heRtguGWDzcaBlgAzKhQa0EVNpPEKzQuBwZ8S8WaCeQ==",
+      "dev": true,
+      "requires": {
+        "has-flag": "^4.0.0",
+        "supports-color": "^7.0.0"
+      },
+      "dependencies": {
+        "supports-color": {
+          "version": "7.2.0",
+          "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+          "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+          "dev": true,
+          "requires": {
+            "has-flag": "^4.0.0"
+          }
+        }
+      }
+    },
+    "supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="
+    },
+    "svg-tags": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/svg-tags/-/svg-tags-1.0.0.tgz",
+      "integrity": "sha1-WPcc7jvVGbWdSyqEO2x95krAR2Q=",
+      "dev": true,
+      "peer": true
+    },
+    "symbol-tree": {
+      "version": "3.2.4",
+      "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+      "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+      "dev": true
+    },
+    "table": {
+      "version": "6.8.0",
+      "resolved": "https://registry.npmjs.org/table/-/table-6.8.0.tgz",
+      "integrity": "sha512-s/fitrbVeEyHKFa7mFdkuQMWlH1Wgw/yEXMt5xACT4ZpzWFluehAxRtUUQKPuWhaLAWhFcVx6w3oC8VKaUfPGA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "ajv": "^8.0.1",
+        "lodash.truncate": "^4.4.2",
+        "slice-ansi": "^4.0.0",
+        "string-width": "^4.2.3",
+        "strip-ansi": "^6.0.1"
+      },
+      "dependencies": {
+        "ajv": {
+          "version": "8.11.0",
+          "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.11.0.tgz",
+          "integrity": "sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==",
+          "dev": true,
+          "peer": true,
+          "requires": {
+            "fast-deep-equal": "^3.1.1",
+            "json-schema-traverse": "^1.0.0",
+            "require-from-string": "^2.0.2",
+            "uri-js": "^4.2.2"
+          }
+        },
+        "json-schema-traverse": {
+          "version": "1.0.0",
+          "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+          "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+          "dev": true,
+          "peer": true
+        },
+        "slice-ansi": {
+          "version": "4.0.0",
+          "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-4.0.0.tgz",
+          "integrity": "sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==",
+          "dev": true,
+          "peer": true,
+          "requires": {
+            "ansi-styles": "^4.0.0",
+            "astral-regex": "^2.0.0",
+            "is-fullwidth-code-point": "^3.0.0"
+          }
+        }
+      }
+    },
+    "tapable": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.0.tgz",
+      "integrity": "sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==",
+      "dev": true,
+      "peer": true
+    },
+    "terminal-link": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz",
+      "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==",
+      "dev": true,
+      "requires": {
+        "ansi-escapes": "^4.2.1",
+        "supports-hyperlinks": "^2.0.0"
+      }
+    },
+    "terser": {
+      "version": "5.14.2",
+      "resolved": "https://registry.npmjs.org/terser/-/terser-5.14.2.tgz",
+      "integrity": "sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@jridgewell/source-map": "^0.3.2",
+        "acorn": "^8.5.0",
+        "commander": "^2.20.0",
+        "source-map-support": "~0.5.20"
+      }
+    },
+    "terser-webpack-plugin": {
+      "version": "5.2.3",
+      "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.2.3.tgz",
+      "integrity": "sha512-eDbuaDlXhVaaoKuLD3DTNTozKqln6xOG6Us0SzlKG5tNlazG+/cdl8pm9qiF1Di89iWScTI0HcO+CDcf2dkXiw==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "jest-worker": "^27.0.6",
+        "p-limit": "^3.1.0",
+        "schema-utils": "^3.1.1",
+        "serialize-javascript": "^6.0.0",
+        "source-map": "^0.6.1",
+        "terser": "^5.7.2"
+      }
+    },
+    "test-exclude": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
+      "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==",
+      "dev": true,
+      "requires": {
+        "@istanbuljs/schema": "^0.1.2",
+        "glob": "^7.1.4",
+        "minimatch": "^3.0.4"
+      }
+    },
+    "text-table": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
+      "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=",
+      "dev": true
+    },
+    "throat": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.1.tgz",
+      "integrity": "sha512-8hmiGIJMDlwjg7dlJ4yKGLK8EsYqKgPWbG3b4wjJddKNwc7N7Dpn08Df4szr/sZdMVeOstrdYSsqzX6BYbcB+w==",
+      "dev": true
+    },
+    "throttle-debounce": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-3.0.1.tgz",
+      "integrity": "sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg=="
+    },
+    "throttleit": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-1.0.0.tgz",
+      "integrity": "sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=",
+      "dev": true
+    },
+    "through": {
+      "version": "2.3.8",
+      "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
+      "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=",
+      "dev": true
+    },
+    "tmp": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
+      "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
+      "dev": true,
+      "requires": {
+        "rimraf": "^3.0.0"
+      }
+    },
+    "tmpl": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
+      "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
+      "dev": true
+    },
+    "to-fast-properties": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",
+      "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="
+    },
+    "to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "requires": {
+        "is-number": "^7.0.0"
+      }
+    },
+    "toggle-selection": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
+      "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI="
+    },
+    "tough-cookie": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz",
+      "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==",
+      "dev": true,
+      "requires": {
+        "psl": "^1.1.28",
+        "punycode": "^2.1.1"
+      }
+    },
+    "tr46": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz",
+      "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.1"
+      }
+    },
+    "trim-newlines": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-3.0.1.tgz",
+      "integrity": "sha512-c1PTsA3tYrIsLGkJkzHF+w9F2EyxfXGo4UyJc4pFL++FMjnq0HJS69T3M7d//gKrFKwy429bouPescbjecU+Zw==",
+      "dev": true,
+      "peer": true
+    },
+    "ts-easing": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/ts-easing/-/ts-easing-0.2.0.tgz",
+      "integrity": "sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ=="
+    },
+    "ts-jest": {
+      "version": "27.1.3",
+      "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.1.3.tgz",
+      "integrity": "sha512-6Nlura7s6uM9BVUAoqLH7JHyMXjz8gluryjpPXxr3IxZdAXnU6FhjvVLHFtfd1vsE1p8zD1OJfskkc0jhTSnkA==",
+      "dev": true,
+      "requires": {
+        "bs-logger": "0.x",
+        "fast-json-stable-stringify": "2.x",
+        "jest-util": "^27.0.0",
+        "json5": "2.x",
+        "lodash.memoize": "4.x",
+        "make-error": "1.x",
+        "semver": "7.x",
+        "yargs-parser": "20.x"
+      }
+    },
+    "ts-node": {
+      "version": "10.7.0",
+      "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.7.0.tgz",
+      "integrity": "sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==",
+      "dev": true,
+      "optional": true,
+      "peer": true,
+      "requires": {
+        "@cspotcode/source-map-support": "0.7.0",
+        "@tsconfig/node10": "^1.0.7",
+        "@tsconfig/node12": "^1.0.7",
+        "@tsconfig/node14": "^1.0.0",
+        "@tsconfig/node16": "^1.0.2",
+        "acorn": "^8.4.1",
+        "acorn-walk": "^8.1.1",
+        "arg": "^4.1.0",
+        "create-require": "^1.1.0",
+        "diff": "^4.0.1",
+        "make-error": "^1.1.1",
+        "v8-compile-cache-lib": "^3.0.0",
+        "yn": "3.1.1"
+      },
+      "dependencies": {
+        "acorn-walk": {
+          "version": "8.2.0",
+          "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz",
+          "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==",
+          "dev": true,
+          "optional": true,
+          "peer": true
+        }
+      }
+    },
+    "tsconfig-paths": {
+      "version": "3.14.1",
+      "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",
+      "integrity": "sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==",
+      "dev": true,
+      "requires": {
+        "@types/json5": "^0.0.29",
+        "json5": "^1.0.1",
+        "minimist": "^1.2.6",
+        "strip-bom": "^3.0.0"
+      },
+      "dependencies": {
+        "json5": {
+          "version": "1.0.1",
+          "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
+          "integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
+          "dev": true,
+          "requires": {
+            "minimist": "^1.2.0"
+          }
+        },
+        "strip-bom": {
+          "version": "3.0.0",
+          "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
+          "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=",
+          "dev": true
+        }
+      }
+    },
+    "tslib": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
+      "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
+    },
+    "tsutils": {
+      "version": "3.21.0",
+      "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz",
+      "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==",
+      "dev": true,
+      "requires": {
+        "tslib": "^1.8.1"
+      }
+    },
+    "tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=",
+      "dev": true,
+      "requires": {
+        "safe-buffer": "^5.0.1"
+      }
+    },
+    "tweetnacl": {
+      "version": "0.14.5",
+      "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
+      "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=",
+      "dev": true
+    },
+    "type-check": {
+      "version": "0.3.2",
+      "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz",
+      "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=",
+      "dev": true,
+      "requires": {
+        "prelude-ls": "~1.1.2"
+      }
+    },
+    "type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "dev": true
+    },
+    "type-fest": {
+      "version": "0.21.3",
+      "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
+      "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
+      "dev": true
+    },
+    "typedarray-to-buffer": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz",
+      "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==",
+      "dev": true,
+      "requires": {
+        "is-typedarray": "^1.0.0"
+      }
+    },
+    "typescript": {
+      "version": "4.5.4",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.4.tgz",
+      "integrity": "sha512-VgYs2A2QIRuGphtzFV7aQJduJ2gyfTljngLzjpfW9FoYZF6xuw1W0vW9ghCKLfcWrCFxK81CSGRAvS1pn4fIUg==",
+      "dev": true
+    },
+    "unbox-primitive": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.1.tgz",
+      "integrity": "sha512-tZU/3NqK3dA5gpE1KtyiJUrEB0lxnGkMFHptJ7q6ewdZ8s12QrODwNbhIJStmJkd1QDXa1NRA8aF2A1zk/Ypyw==",
+      "dev": true,
+      "requires": {
+        "function-bind": "^1.1.1",
+        "has-bigints": "^1.0.1",
+        "has-symbols": "^1.0.2",
+        "which-boxed-primitive": "^1.0.2"
+      }
+    },
+    "universalify": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
+      "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==",
+      "dev": true
+    },
+    "unload": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
+      "integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
+      "requires": {
+        "@babel/runtime": "^7.6.2",
+        "detect-node": "^2.0.4"
+      }
+    },
+    "untildify": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz",
+      "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==",
+      "dev": true
+    },
+    "uri-js": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+      "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+      "dev": true,
+      "requires": {
+        "punycode": "^2.1.0"
+      }
+    },
+    "url": {
+      "version": "0.11.0",
+      "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz",
+      "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=",
+      "dev": true,
+      "requires": {
+        "punycode": "1.3.2",
+        "querystring": "0.2.0"
+      },
+      "dependencies": {
+        "punycode": {
+          "version": "1.3.2",
+          "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
+          "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=",
+          "dev": true
+        }
+      }
+    },
+    "util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
+      "dev": true
+    },
+    "uuid": {
+      "version": "8.3.2",
+      "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
+      "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
+      "dev": true
+    },
+    "v8-compile-cache": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz",
+      "integrity": "sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==",
+      "dev": true
+    },
+    "v8-compile-cache-lib": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.0.tgz",
+      "integrity": "sha512-mpSYqfsFvASnSn5qMiwrr4VKfumbPyONLCOPmsR3A6pTY/r0+tSaVbgPWSAIuzbk3lCTa+FForeTiO+wBQGkjA==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "v8-to-istanbul": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-8.1.1.tgz",
+      "integrity": "sha512-FGtKtv3xIpR6BYhvgH8MI/y78oT7d8Au3ww4QIxymrCtZEh5b8gCw2siywE+puhEmuWKDtmfrvF5UlB298ut3w==",
+      "dev": true,
+      "requires": {
+        "@types/istanbul-lib-coverage": "^2.0.1",
+        "convert-source-map": "^1.6.0",
+        "source-map": "^0.7.3"
+      },
+      "dependencies": {
+        "source-map": {
+          "version": "0.7.3",
+          "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.3.tgz",
+          "integrity": "sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==",
+          "dev": true
+        }
+      }
+    },
+    "validate-npm-package-license": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz",
+      "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "spdx-correct": "^3.0.0",
+        "spdx-expression-parse": "^3.0.0"
+      }
+    },
+    "verror": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
+      "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=",
+      "dev": true,
+      "requires": {
+        "assert-plus": "^1.0.0",
+        "core-util-is": "1.0.2",
+        "extsprintf": "^1.2.0"
+      }
+    },
+    "w3c-hr-time": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz",
+      "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==",
+      "dev": true,
+      "requires": {
+        "browser-process-hrtime": "^1.0.0"
+      }
+    },
+    "w3c-xmlserializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz",
+      "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==",
+      "dev": true,
+      "requires": {
+        "xml-name-validator": "^3.0.0"
+      }
+    },
+    "walker": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
+      "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==",
+      "dev": true,
+      "requires": {
+        "makeerror": "1.0.12"
+      }
+    },
+    "watchpack": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.2.0.tgz",
+      "integrity": "sha512-up4YAn/XHgZHIxFBVCdlMiWDj6WaLKpwVeGQk2I5thdYxF/KmF0aaz6TfJZ/hfl1h/XlcDr7k1KH7ThDagpFaA==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "glob-to-regexp": "^0.4.1",
+        "graceful-fs": "^4.1.2"
+      }
+    },
+    "web-streams-polyfill": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.0.tgz",
+      "integrity": "sha512-EqPmREeOzttaLRm5HS7io98goBgZ7IVz79aDvqjD0kYXLtFZTc0T/U6wHTPKyIjb+MdN7DFIIX6hgdBEpWmfPA==",
+      "dev": true
+    },
+    "webidl-conversions": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz",
+      "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==",
+      "dev": true
+    },
+    "webpack": {
+      "version": "5.52.0",
+      "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.52.0.tgz",
+      "integrity": "sha512-yRZOat8jWGwBwHpco3uKQhVU7HYaNunZiJ4AkAVQkPCUGoZk/tiIXiwG+8HIy/F+qsiZvSOa+GLQOj3q5RKRYg==",
+      "dev": true,
+      "peer": true,
+      "requires": {
+        "@types/eslint-scope": "^3.7.0",
+        "@types/estree": "^0.0.50",
+        "@webassemblyjs/ast": "1.11.1",
+        "@webassemblyjs/wasm-edit": "1.11.1",
+        "@webassemblyjs/wasm-parser": "1.11.1",
+        "acorn": "^8.4.1",
+        "acorn-import-assertions": "^1.7.6",
+        "browserslist": "^4.14.5",
+        "chrome-trace-event": "^1.0.2",
+        "enhanced-resolve": "^5.8.0",
+        "es-module-lexer": "^0.7.1",
+        "eslint-scope": "5.1.1",
+        "events": "^3.2.0",
+        "glob-to-regexp": "^0.4.1",
+        "graceful-fs": "^4.2.4",
+        "json-parse-better-errors": "^1.0.2",
+        "loader-runner": "^4.2.0",
+        "mime-types": "^2.1.27",
+        "neo-async": "^2.6.2",
+        "schema-utils": "^3.1.0",
+        "tapable": "^2.1.1",
+        "terser-webpack-plugin": "^5.1.3",
+        "watchpack": "^2.2.0",
+        "webpack-sources": "^3.2.0"
+      }
+    },
+    "webpack-sources": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.0.tgz",
+      "integrity": "sha512-fahN08Et7P9trej8xz/Z7eRu8ltyiygEo/hnRi9KqBUs80KeDcnf96ZJo++ewWd84fEf3xSX9bp4ZS9hbw0OBw==",
+      "dev": true,
+      "peer": true
+    },
+    "whatwg-encoding": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz",
+      "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==",
+      "dev": true,
+      "requires": {
+        "iconv-lite": "0.4.24"
+      }
+    },
+    "whatwg-mimetype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz",
+      "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==",
+      "dev": true
+    },
+    "whatwg-url": {
+      "version": "8.7.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz",
+      "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==",
+      "dev": true,
+      "requires": {
+        "lodash": "^4.7.0",
+        "tr46": "^2.1.0",
+        "webidl-conversions": "^6.1.0"
+      }
+    },
+    "which": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+      "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+      "dev": true,
+      "requires": {
+        "isexe": "^2.0.0"
+      }
+    },
+    "which-boxed-primitive": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz",
+      "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==",
+      "dev": true,
+      "requires": {
+        "is-bigint": "^1.0.1",
+        "is-boolean-object": "^1.1.0",
+        "is-number-object": "^1.0.4",
+        "is-string": "^1.0.5",
+        "is-symbol": "^1.0.3"
+      }
+    },
+    "wicg-inert": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/wicg-inert/-/wicg-inert-3.1.1.tgz",
+      "integrity": "sha512-PhBaNh8ur9Xm4Ggy4umelwNIP6pPP1bv3EaWaKqfb/QNme2rdLjm7wIInvV4WhxVHhzA4Spgw9qNSqWtB/ca2A=="
+    },
+    "word-wrap": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.3.tgz",
+      "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
+      "dev": true
+    },
+    "wrap-ansi": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+      "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
+      "dev": true,
+      "requires": {
+        "ansi-styles": "^4.0.0",
+        "string-width": "^4.1.0",
+        "strip-ansi": "^6.0.0"
+      }
+    },
+    "wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8="
+    },
+    "write-file-atomic": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz",
+      "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==",
+      "dev": true,
+      "requires": {
+        "imurmurhash": "^0.1.4",
+        "is-typedarray": "^1.0.0",
+        "signal-exit": "^3.0.2",
+        "typedarray-to-buffer": "^3.1.5"
+      }
+    },
+    "xml-name-validator": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz",
+      "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==",
+      "dev": true
+    },
+    "xmlchars": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+      "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+      "dev": true
+    },
+    "y18n": {
+      "version": "5.0.8",
+      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+      "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+      "dev": true
+    },
+    "yallist": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+      "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+      "dev": true
+    },
+    "yaml": {
+      "version": "1.10.2",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
+      "integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg=="
+    },
+    "yargs": {
+      "version": "16.2.0",
+      "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+      "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+      "dev": true,
+      "requires": {
+        "cliui": "^7.0.2",
+        "escalade": "^3.1.1",
+        "get-caller-file": "^2.0.5",
+        "require-directory": "^2.1.1",
+        "string-width": "^4.2.0",
+        "y18n": "^5.0.5",
+        "yargs-parser": "^20.2.2"
+      }
+    },
+    "yargs-parser": {
+      "version": "20.2.4",
+      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.4.tgz",
+      "integrity": "sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==",
+      "dev": true
+    },
+    "yauzl": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+      "integrity": "sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=",
+      "dev": true,
+      "requires": {
+        "buffer-crc32": "~0.2.3",
+        "fd-slicer": "~1.1.0"
+      }
+    },
+    "yn": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
+      "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
+      "dev": true,
+      "optional": true,
+      "peer": true
+    },
+    "yocto-queue": {
+      "version": "0.1.0",
+      "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+      "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+      "dev": true,
+      "peer": true
+    }
+  }
+}
diff --git a/analysis/frontend/ui/package.json b/analysis/frontend/ui/package.json
new file mode 100644
index 0000000..5edc529
--- /dev/null
+++ b/analysis/frontend/ui/package.json
@@ -0,0 +1,83 @@
+{
+  "name": "weetbix",
+  "version": "1.0.0",
+  "description": "Weetbix UI",
+  "private": true,
+  "scripts": {
+    "build": "tsc --noEmit --skipLibCheck && node esbuild.mjs",
+    "watch": "tsc --noEmit --skipLibCheck && node esbuild_watch.mjs",
+    "typecheck": "tsc --noEmit --skipLibCheck",
+    "test": "tsc --noEmit --skipLibCheck && jest && cypress run"
+  },
+  "author": "mwarton@google.com",
+  "devDependencies": {
+    "@testing-library/jest-dom": "^5.16.2",
+    "@testing-library/react": "^12.1.4",
+    "@testing-library/react-hooks": "^7.0.2",
+    "@testing-library/user-event": "^13.5.0",
+    "@types/jest": "^27.4.1",
+    "@types/luxon": "^2.0.7",
+    "@types/node": "^17.0.2",
+    "@types/react": "^17.0.39",
+    "@types/react-dom": "^17.0.13",
+    "@typescript-eslint/eslint-plugin": "^5.16.0",
+    "@typescript-eslint/parser": "^5.16.0",
+    "css-loader": "^6.2.0",
+    "cypress": "^8.3.1",
+    "esbuild": "^0.14.10",
+    "eslint": "^8.11.0",
+    "eslint-config-google": "^0.14.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-import": "^2.25.4",
+    "eslint-plugin-jest": "^26.1.3",
+    "eslint-plugin-jsx-a11y": "^6.5.1",
+    "eslint-plugin-prettier": "^4.0.0",
+    "eslint-plugin-react": "^7.29.4",
+    "eslint-plugin-react-hooks": "^4.5.0",
+    "fetch-mock-jest": "^1.5.1",
+    "identity-obj-proxy": "^3.0.0",
+    "jest": "^27.5.1",
+    "node-fetch": "^3.2.3",
+    "react-test-renderer": "^17.0.2",
+    "sass-loader": "^12.6.0",
+    "style-loader": "^3.2.1",
+    "stylelint-config-css-modules": "^4.1.0",
+    "stylelint-config-recess-order": "^3.0.0",
+    "stylelint-config-standard": "^25.0.0",
+    "stylelint-scss": "^4.2.0",
+    "ts-jest": "^27.1.3",
+    "typescript": "^4.5.4"
+  },
+  "dependencies": {
+    "@chopsui/prpc-client": "^1.1.0",
+    "@emotion/react": "^11.8.2",
+    "@fontsource/roboto": "^4.5.3",
+    "@material/mwc-button": "^0.25.3",
+    "@material/mwc-checkbox": "^0.25.3",
+    "@material/mwc-circular-progress": "^0.25.3",
+    "@material/mwc-dialog": "^0.25.3",
+    "@material/mwc-formfield": "^0.25.3",
+    "@material/mwc-icon": "^0.25.3",
+    "@material/mwc-list": "^0.25.3",
+    "@material/mwc-select": "^0.25.3",
+    "@material/mwc-snackbar": "^0.25.3",
+    "@material/mwc-switch": "^0.25.3",
+    "@material/mwc-textarea": "^0.25.3",
+    "@material/mwc-textfield": "^0.25.3",
+    "@mui/icons-material": "^5.8.4",
+    "@mui/lab": "^5.0.0-alpha.92",
+    "@mui/material": "^5.9.2",
+    "@vaadin/router": "^1.7.4",
+    "dayjs": "^1.10.8",
+    "esbuild-sass-plugin": "^2.2.3",
+    "lit-element": "^2.5.1",
+    "luxon": "^2.1.1",
+    "nanoid": "^3.3.3",
+    "react": "^17.0.2",
+    "react-chartjs-2": "^4.0.1",
+    "react-dom": "^17.0.2",
+    "react-query": "^3.34.16",
+    "react-router-dom": "^6.2.2",
+    "react-use": "^17.3.2"
+  }
+}
diff --git a/analysis/frontend/ui/src/api/auth_state.ts b/analysis/frontend/ui/src/api/auth_state.ts
new file mode 100644
index 0000000..253ab72
--- /dev/null
+++ b/analysis/frontend/ui/src/api/auth_state.ts
@@ -0,0 +1,56 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+let authState : AuthState | null = null;
+
+// eslint-disable-next-line valid-jsdoc
+/**
+ * obtainAuthState obtains a current auth state, for interacting
+ * with pRPC APIs.
+ * @return the current auth state.
+ */
+export async function obtainAuthState(): Promise<AuthState> {
+  if (authState != null &&
+            authState.accessTokenExpiry * 1000 > (Date.now() + 5000) &&
+            authState.idTokenExpiry * 1000 > (Date.now() + 5000)) {
+    // Auth state is still has >=5 seconds of validity for
+    // both tokens.
+    return authState;
+  }
+
+  // Refresh the auth state.
+  const response = await queryAuthState();
+  authState = response;
+  return authState;
+}
+
+export interface AuthState {
+    identity: string;
+    email: string;
+    picture: string;
+    accessToken: string;
+    idToken: string;
+    // Expiration time (unix timestamp) of the access token.
+    // If zero/undefined, the access token does not expire.
+    accessTokenExpiry: number;
+    idTokenExpiry: number;
+}
+
+export async function queryAuthState(): Promise<AuthState> {
+  const res = await fetch('/api/authState');
+  if (!res.ok) {
+    throw new Error('failed to get authState:\n' + (await res.text()));
+  }
+  return res.json();
+}
diff --git a/analysis/frontend/ui/src/clients/authorized_client.ts b/analysis/frontend/ui/src/clients/authorized_client.ts
new file mode 100644
index 0000000..b5835fd
--- /dev/null
+++ b/analysis/frontend/ui/src/clients/authorized_client.ts
@@ -0,0 +1,62 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { PrpcClient } from '@chopsui/prpc-client';
+
+import { obtainAuthState } from '../api/auth_state';
+
+export class AuthorizedPrpcClient {
+  client: PrpcClient;
+  // Should the ID token be used to authorize the request, or the access token?
+  useIDToken: boolean;
+
+  // Initialises a new AuthorizedPrpcClient that connects to host.
+  // To connect to Weetbix, leave host unspecified.
+  constructor(host?: string, useIDToken?: boolean) {
+    // Only allow insecure connections in Weetbix in local development,
+    // where risk of man-in-the-middle attack to server is negligible.
+    const insecure = document.location.protocol === 'http:' && !host;
+    const hostname = document.location.hostname;
+    if (insecure && hostname !== 'localhost' && hostname !== '127.0.0.1') {
+      // Server misconfiguration.
+      throw new Error('Weetbix should never be served over http: outside local development.');
+    }
+    this.client = new PrpcClient({
+      host: host,
+      insecure: insecure,
+    });
+    this.useIDToken = useIDToken === true;
+  }
+
+  async call(service: string, method: string, message: object, additionalHeaders?: {
+        [key: string]: string;
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any
+    } | undefined): Promise<any> {
+    // Although PrpcClient allows us to pass a token to the constructor,
+    // we prefer to inject it at request time to ensure the most recent
+    // token is used.
+    const authState = await obtainAuthState();
+    let token: string;
+    if (this.useIDToken) {
+      token = authState.idToken;
+    } else {
+      token = authState.accessToken;
+    }
+    additionalHeaders = {
+      Authorization: 'Bearer ' + token,
+      ...additionalHeaders,
+    };
+    return this.client.call(service, method, message, additionalHeaders);
+  }
+}
diff --git a/analysis/frontend/ui/src/components/bug_picker/bug_picker.test.tsx b/analysis/frontend/ui/src/components/bug_picker/bug_picker.test.tsx
new file mode 100644
index 0000000..bd307b6
--- /dev/null
+++ b/analysis/frontend/ui/src/components/bug_picker/bug_picker.test.tsx
@@ -0,0 +1,61 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import fetchMock from 'fetch-mock-jest';
+
+import { screen } from '@testing-library/react';
+
+import { identityFunction } from '../../testing_tools/functions';
+import { renderWithRouterAndClient } from '../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../testing_tools/mocks/authstate_mock';
+import { mockFetchProjectConfig } from '../../testing_tools/mocks/projects_mock';
+import BugPicker from './bug_picker';
+
+describe('Test BugPicker component', () => {
+  beforeEach(() => {
+    mockFetchAuthState();
+    mockFetchProjectConfig();
+  });
+
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given a bug and a project, should display select and a text box for writing the bug id', async () => {
+    renderWithRouterAndClient(
+        <BugPicker
+          bugId="chromium/123456"
+          bugSystem="monorail"
+          handleBugSystemChanged={identityFunction}
+          handleBugIdChanged={identityFunction}/>, '/p/chromium', '/p/:project');
+    await screen.findByText('Bug tracker');
+    expect(screen.getByTestId('bug-system')).toHaveValue('monorail');
+    expect(screen.getByTestId('bug-number')).toHaveValue('123456');
+  });
+
+  it('given a buganizer bug, should select the bug system correctly', async () => {
+    renderWithRouterAndClient(
+        <BugPicker
+          bugId="123456"
+          bugSystem="buganizer"
+          handleBugSystemChanged={identityFunction}
+          handleBugIdChanged={identityFunction}/>, '/p/chromium', '/p/:project');
+    await screen.findByText('Bug tracker');
+    expect(screen.getByTestId('bug-system')).toHaveValue('buganizer');
+    expect(screen.getByTestId('bug-number')).toHaveValue('123456');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/bug_picker/bug_picker.tsx b/analysis/frontend/ui/src/components/bug_picker/bug_picker.tsx
new file mode 100644
index 0000000..3f32eb5
--- /dev/null
+++ b/analysis/frontend/ui/src/components/bug_picker/bug_picker.tsx
@@ -0,0 +1,195 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { ChangeEvent } from 'react';
+import { useQuery } from 'react-query';
+import { useParams } from 'react-router-dom';
+
+import CircularProgress from '@mui/material/CircularProgress';
+import FormControl from '@mui/material/FormControl';
+import Grid from '@mui/material/Grid';
+import InputLabel from '@mui/material/InputLabel';
+import MenuItem from '@mui/material/MenuItem';
+import Select, { SelectChangeEvent } from '@mui/material/Select';
+import TextField from '@mui/material/TextField';
+
+import { getProjectsService, GetProjectConfigRequest } from '../../services/project';
+import ErrorAlert from '../error_alert/error_alert';
+
+interface Props {
+    bugSystem: string;
+    bugId: string;
+    handleBugSystemChanged: (bugSystem: string) => void;
+    handleBugIdChanged: (bugId: string) => void;
+}
+
+const getMonorailSystem = (bugId: string): string | null => {
+  if (bugId.indexOf('/') >= 0) {
+    const parts = bugId.split('/');
+    return parts[0];
+  } else {
+    return null;
+  }
+};
+
+const getBugNumber = (bugId: string): string => {
+  if (bugId.indexOf('/') >= 0) {
+    const parts = bugId.split('/');
+    return parts[1];
+  } else {
+    return bugId;
+  }
+};
+
+/**
+ * An enum representing the supported bug systems.
+ *
+ * This is needed because mui's <Select> doesn't compare string correctly in typescript.
+ */
+enum BugSystems {
+    MONORAIL = 'monorail',
+    BUGANIZER = 'buganizer',
+}
+
+/**
+ * This method works around the fact that Select
+ * components compare strings for reference.
+ *
+ * @param {string} bugSystem The bug system to find in the enum.
+ * @return {string} A static enum value equal to the string
+ *          provided and used in the Select component.
+ */
+const getStaticBugSystem = (bugSystem: string): string => {
+  switch (bugSystem) {
+    case 'monorail': {
+      return BugSystems.MONORAIL;
+    }
+    case 'buganizer': {
+      return BugSystems.BUGANIZER;
+    }
+    default: {
+      throw new Error('Unnkown bug system.');
+    }
+  }
+};
+
+const BugPicker = ({
+  bugSystem,
+  bugId,
+  handleBugSystemChanged,
+  handleBugIdChanged,
+}: Props) => {
+  const { project } = useParams();
+
+  const {
+    isLoading,
+    isError,
+    data: projectConfig,
+    error,
+  } = useQuery(['projectconfig', project], async () => {
+    if (!project) {
+      throw new Error('invariant violated: project should be set');
+    }
+    const projectService = getProjectsService();
+    const request: GetProjectConfigRequest = {
+      name: `projects/${encodeURIComponent(project)}/config`,
+    };
+    return await projectService.getConfig(request);
+  }, {
+    enabled: !!project,
+  });
+
+  if (!project) {
+    return (
+      <ErrorAlert
+        showError
+        errorTitle="Project not defined"
+        errorText={'No project param detected.}'}/>
+    );
+  }
+
+  const selectedBugSystem = getStaticBugSystem(bugSystem);
+
+  if (isLoading) {
+    return (
+      <Grid container justifyContent="center">
+        <CircularProgress data-testid="circle-loading" />
+      </Grid>
+    );
+  }
+
+  if (isError || !projectConfig) {
+    return <ErrorAlert
+      showError
+      errorTitle="Failed to load project config"
+      errorText={`An error occured while fetching the project config: ${error}`}/>;
+  }
+
+  const monorailSystem = getMonorailSystem(bugId);
+
+  const onBugSystemChange = (e: SelectChangeEvent<typeof bugSystem>) => {
+    handleBugSystemChanged(e.target.value);
+
+    // When the bug system changes, we also need to update the Bug ID.
+    if (e.target.value == 'monorail') {
+      handleBugIdChanged(`${projectConfig.monorail.project}/${getBugNumber(bugId)}`);
+    } else if (e.target.value == 'buganizer') {
+      handleBugIdChanged(getBugNumber(bugId));
+    }
+  };
+
+  const onBugNumberChange = (e: ChangeEvent<HTMLInputElement>) => {
+    const enteredBugId = e.target.value;
+
+    if (monorailSystem != null) {
+      handleBugIdChanged(`${monorailSystem}/${enteredBugId}`);
+    } else {
+      handleBugIdChanged(enteredBugId);
+    }
+  };
+
+  return (
+    <Grid container item columnSpacing={1} sx={{ mt: 1 }}>
+      <Grid item xs={6}>
+        <FormControl variant="standard" fullWidth>
+          <InputLabel id="bug-picker_select-bug-tracker-label">Bug tracker</InputLabel>
+          <Select
+            labelId="bug-picker_select-bug-tracker-label"
+            id="bug-picker_select-bug-tracker"
+            value={selectedBugSystem}
+            onChange={onBugSystemChange}
+            variant="standard"
+            inputProps={{ 'data-testid': 'bug-system' }}>
+            <MenuItem value={getStaticBugSystem('monorail')}>
+              {projectConfig.monorail.displayPrefix}
+            </MenuItem>
+            <MenuItem value={getStaticBugSystem('buganizer')}>
+                Buganizer
+            </MenuItem>
+          </Select>
+        </FormControl>
+      </Grid>
+      <Grid item xs={6}>
+        <TextField
+          label="Bug number"
+          variant="standard"
+          inputProps={{ 'data-testid': 'bug-number' }}
+          value={getBugNumber(bugId)}
+          onChange={onBugNumberChange}/>
+      </Grid>
+    </Grid>
+  );
+};
+
+export default BugPicker;
diff --git a/analysis/frontend/ui/src/components/circular_progress_with_label/circular_progress_with_label.tsx b/analysis/frontend/ui/src/components/circular_progress_with_label/circular_progress_with_label.tsx
new file mode 100644
index 0000000..a56333c
--- /dev/null
+++ b/analysis/frontend/ui/src/components/circular_progress_with_label/circular_progress_with_label.tsx
@@ -0,0 +1,45 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Box from '@mui/material/Box';
+import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress';
+import Typography from '@mui/material/Typography';
+
+const CircularProgressWithLabel = (
+    props: CircularProgressProps & { value: number },
+) => {
+  return (
+    <Box sx={{ position: 'relative', display: 'inline-flex' }}>
+      <CircularProgress variant="determinate" {...props} />
+      <Box
+        sx={{
+          top: 0,
+          left: 0,
+          bottom: 0,
+          right: 0,
+          position: 'absolute',
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+        }}>
+        <Typography
+          variant="caption"
+          component="div"
+          color="text.secondary">{`${Math.round(props.value)}%`}</Typography>
+      </Box>
+    </Box>
+  );
+};
+
+export default CircularProgressWithLabel;
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table.test.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table.test.tsx
new file mode 100644
index 0000000..6910f38
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table.test.tsx
@@ -0,0 +1,169 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  fireEvent,
+  screen,
+} from '@testing-library/react';
+
+import { QueryClusterSummariesRequest, QueryClusterSummariesResponse } from '../../services/cluster';
+import { renderWithRouterAndClient } from '../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../testing_tools/mocks/authstate_mock';
+import {
+  getMockSuggestedClusterSummary,
+  getMockRuleClusterSummary,
+  mockQueryClusterSummaries,
+} from '../../testing_tools/mocks/cluster_mock';
+import ClustersTable from './clusters_table';
+
+describe('Test ClustersTable component', () => {
+  beforeEach(() => {
+    mockFetchAuthState();
+  });
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given clusters, it should display them', async () => {
+    const mockClusters = [
+      getMockSuggestedClusterSummary('1234567890abcedf1234567890abcedf'),
+      getMockRuleClusterSummary('10000000000000001000000000000000'),
+    ];
+    const request: QueryClusterSummariesRequest = {
+      project: 'testproject',
+      orderBy: 'critical_failures_exonerated desc',
+      failureFilter: '',
+    };
+    const response: QueryClusterSummariesResponse = { clusterSummaries: mockClusters };
+    mockQueryClusterSummaries(request, response);
+
+    renderWithRouterAndClient(
+        <ClustersTable
+          project="testproject"/>,
+    );
+
+    await screen.findByTestId('clusters_table_body');
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    expect(screen.getByText(mockClusters[1].bug!.linkText)).toBeInTheDocument();
+  });
+
+  it('given no clusters, it should display an appropriate message', async () => {
+    const request: QueryClusterSummariesRequest = {
+      project: 'testproject',
+      orderBy: 'critical_failures_exonerated desc',
+      failureFilter: '',
+    };
+    const response: QueryClusterSummariesResponse = { clusterSummaries: [] };
+    mockQueryClusterSummaries(request, response);
+
+    renderWithRouterAndClient(
+        <ClustersTable
+          project="testproject"/>,
+    );
+
+    await screen.findByTestId('clusters_table_body');
+
+    expect(screen.getByText('Hooray! There are no failures matching the specified criteria.')).toBeInTheDocument();
+  });
+
+  it('when clicking a sortable column then should modify cluster order', async () => {
+    const suggestedCluster = getMockSuggestedClusterSummary('1234567890abcedf1234567890abcedf');
+    const ruleCluster = getMockRuleClusterSummary('10000000000000001000000000000000');
+    const request: QueryClusterSummariesRequest = {
+      project: 'testproject',
+      orderBy: 'critical_failures_exonerated desc',
+      failureFilter: '',
+    };
+    const response: QueryClusterSummariesResponse = {
+      clusterSummaries: [suggestedCluster, ruleCluster],
+    };
+    mockQueryClusterSummaries(request, response);
+
+    renderWithRouterAndClient(
+        <ClustersTable
+          project="testproject"/>,
+    );
+
+    await screen.findByTestId('clusters_table_body');
+
+    // Prepare an updated set of clusters to show after sorting.
+    const updatedRequest: QueryClusterSummariesRequest = {
+      project: 'testproject',
+      orderBy: 'failures desc',
+      failureFilter: '',
+    };
+    const ruleCluster2 = getMockRuleClusterSummary('20000000000000002000000000000000');
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    ruleCluster2.bug!.linkText = 'crbug.com/2222222';
+    const updatedResponse: QueryClusterSummariesResponse = {
+      clusterSummaries: [suggestedCluster, ruleCluster2],
+    };
+    mockQueryClusterSummaries(updatedRequest, updatedResponse);
+
+    await fireEvent.click(screen.getByText('Total Failures'));
+
+    await screen.findByText('crbug.com/2222222');
+    await screen.findByTestId('clusters_table_body');
+
+    expect(screen.getByText('crbug.com/2222222')).toBeInTheDocument();
+  });
+
+  it('when filtering it should show matching failures', async () => {
+    const suggestedCluster = getMockSuggestedClusterSummary('1234567890abcedf1234567890abcedf');
+    const ruleCluster = getMockRuleClusterSummary('10000000000000001000000000000000');
+    const request: QueryClusterSummariesRequest = {
+      project: 'testproject',
+      orderBy: 'critical_failures_exonerated desc',
+      failureFilter: '',
+    };
+    const response: QueryClusterSummariesResponse = {
+      clusterSummaries: [suggestedCluster, ruleCluster],
+    };
+    mockQueryClusterSummaries(request, response);
+
+    renderWithRouterAndClient(
+        <ClustersTable
+          project="testproject"/>,
+    );
+
+    await screen.findByTestId('clusters_table_body');
+
+    // Prepare an updated set of clusters to show after filtering.
+    const updatedRequest: QueryClusterSummariesRequest = {
+      project: 'testproject',
+      orderBy: 'critical_failures_exonerated desc',
+      failureFilter: 'new_criteria',
+    };
+    const ruleCluster2 = getMockRuleClusterSummary('20000000000000002000000000000000');
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    ruleCluster2.bug!.linkText = 'crbug.com/3333333';
+    const updatedResponse: QueryClusterSummariesResponse = {
+      clusterSummaries: [suggestedCluster, ruleCluster2],
+    };
+    mockQueryClusterSummaries(updatedRequest, updatedResponse);
+
+    fireEvent.change(screen.getByTestId('failure_filter_input'), { target: { value: 'new_criteria' } });
+    fireEvent.blur(screen.getByTestId('failure_filter_input'));
+
+    await screen.findByText('crbug.com/3333333');
+
+    expect(screen.getByText('crbug.com/3333333')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table.tsx
new file mode 100644
index 0000000..c617b3a
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table.tsx
@@ -0,0 +1,135 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  useState,
+} from 'react';
+import { useQuery } from 'react-query';
+import { useSearchParams } from 'react-router-dom';
+
+import CircularProgress from '@mui/material/CircularProgress';
+import Grid from '@mui/material/Grid';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+
+import { getClustersService, QueryClusterSummariesRequest, SortableMetricName } from '../../services/cluster';
+
+import ErrorAlert from '../error_alert/error_alert';
+import ClustersTableFilter from './clusters_table_filter/clusters_table_filter';
+import ClustersTableRow from './clusters_table_row/clusters_table_row';
+import ClustersTableHead from './clusters_table_head/clusters_table_head';
+
+interface Props {
+    project: string;
+}
+
+const ClustersTable = ({
+  project,
+}: Props) => {
+  const [searchParams, setSearchParams] = useSearchParams();
+
+  const [sortMetric, setCurrentSortMetric] = useState<SortableMetricName>('critical_failures_exonerated');
+  const [isAscending, setIsAscending] = useState(false);
+
+  const clustersService = getClustersService();
+
+  const {
+    isLoading,
+    isError,
+    isSuccess,
+    data: clusters,
+    error,
+  } = useQuery(
+      ['clusters', project, searchParams.get('q') || '', sortMetric, isAscending ? 'asc' : 'desc'],
+      async () => {
+        const request : QueryClusterSummariesRequest = {
+          project: project,
+          failureFilter: searchParams.get('q') || '',
+          orderBy: sortMetric + (isAscending ? '' : ' desc'),
+        };
+
+        return await clustersService.queryClusterSummaries(request);
+      },
+  );
+
+
+  const onFailureFilterChanged = (filter: string) => {
+    setSearchParams({ 'q': filter }, { replace: true });
+  };
+
+  const toggleSort = (metric: SortableMetricName) => {
+    if (metric === sortMetric) {
+      setIsAscending(!isAscending);
+    } else {
+      setCurrentSortMetric(metric);
+      setIsAscending(false);
+    }
+  };
+
+  const rows = clusters?.clusterSummaries || [];
+
+  return (
+    <Grid container columnGap={2} rowGap={2}>
+      <ClustersTableFilter
+        failureFilter={searchParams.get('q') || ''}
+        setFailureFilter={onFailureFilterChanged}/>
+      <Grid item xs={12}>
+        <Table size="small" sx={{ overflowWrap: 'anywhere' }}>
+          <ClustersTableHead
+            toggleSort={toggleSort}
+            sortMetric={sortMetric}
+            isAscending={isAscending}/>
+          {
+            isSuccess && (
+              <TableBody data-testid='clusters_table_body'>
+                {
+                  rows.map((row) => (
+                    <ClustersTableRow
+                      key={`${row.clusterId.algorithm}:${row.clusterId.id}`}
+                      project={project}
+                      cluster={row}/>
+                  ))
+                }
+              </TableBody>
+            )
+          }
+        </Table>
+      </Grid>
+      {
+        isSuccess && rows.length === 0 && (
+          <Grid container item alignItems="center" justifyContent="center">
+            Hooray! There are no failures matching the specified criteria.
+          </Grid>
+        )
+      }
+      {
+        isError && (
+          <ErrorAlert
+            errorTitle="Failed to load failures"
+            errorText={`Loading cluster failures failed due to: ${error}`}
+            showError/>
+        )
+      }
+      {
+        isLoading && (
+          <Grid container item alignItems="center" justifyContent="center">
+            <CircularProgress />
+          </Grid>
+        )
+      }
+    </Grid>
+  );
+};
+
+export default ClustersTable;
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table_filter/clusters_table_filter.test.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table_filter/clusters_table_filter.test.tsx
new file mode 100644
index 0000000..37c584c
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table_filter/clusters_table_filter.test.tsx
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import { identityFunction } from '../../../testing_tools/functions';
+import ClustersTableFilter from './clusters_table_filter';
+
+describe('Test ClustersTableFilter component', () => {
+  it('should display the failures filter', async () => {
+    render(
+        <ClustersTableFilter
+          failureFilter=""
+          setFailureFilter={identityFunction}/>,
+    );
+
+    await screen.findByTestId('clusters_table_filter');
+
+    expect(screen.getByTestId('failure_filter')).toBeInTheDocument();
+  });
+
+  it('given an existing filter, the filter should be pre-populated', async () => {
+    render(
+        <ClustersTableFilter
+          failureFilter="some restriction"
+          setFailureFilter={identityFunction}/>,
+    );
+
+    await screen.findByTestId('clusters_table_filter');
+
+    expect(screen.getByTestId('failure_filter_input')).toHaveValue('some restriction');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table_filter/clusters_table_filter.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table_filter/clusters_table_filter.tsx
new file mode 100644
index 0000000..3677b48
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table_filter/clusters_table_filter.tsx
@@ -0,0 +1,118 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  useState,
+} from 'react';
+
+import IconButton from '@mui/material/IconButton';
+import FormControl from '@mui/material/FormControl';
+import Grid from '@mui/material/Grid';
+import TextField from '@mui/material/TextField';
+import Popover from '@mui/material/Popover';
+import InputAdornment from '@mui/material/InputAdornment';
+import HelpOutline from '@mui/icons-material/HelpOutline';
+import Search from '@mui/icons-material/Search';
+import Typography from '@mui/material/Typography';
+
+interface Props {
+    failureFilter: string,
+    setFailureFilter: (filter: string) => void
+}
+
+const FilterHelp = () => {
+  // TODO: more styling on this.
+  return <Typography sx={{ p: 2, maxWidth: '800px' }}>
+    <p>Searching will display clusters and cluster impact based only on test failures that match your search.</p>
+    <p>Searching supports a subset of <a href="https://google.aip.dev/160">AIP-160 filtering</a>.</p>
+    <p>A bare value is searched for in the columns test_id and failure_reason.  Values are case-sensitive. E.g. <b>ninja</b> or <b>&ldquo;test failed&rdquo;</b>.</p>
+    <p>You can use AND, OR and NOT (case sensitive) logical operators, along with grouping. &lsquo;-&rsquo; is equivalent to NOT. Multiple bare values are considered to be AND separated.  These are equivalent: <b>hello world</b> and <b>hello AND world</b>.
+      More examples: <b>a OR b</b> or <b>a AND NOT(b or -c)</b>.</p>
+    <p>You can search particular columns with &lsquo;=&rsquo;, &lsquo;!=&rsquo; and &lsquo;:&rsquo; (has) operators. The right hand side of the operator must be a simple value. E.g. <b>test_id:telemetry</b>, <b>-failure_reason:Timeout</b> or <b>ingested_invocation_id=&ldquo;build-8822963500388678513&rdquo;</b>.</p>
+    <p>Supported columns to search on:
+      <ul>
+        <li>test_id</li>
+        <li>failure_reason</li>
+        <li>realm</li>
+        <li>ingested_invocation_id</li>
+        <li>cluster_algorithm</li>
+        <li>cluster_id</li>
+        <li>variant_hash</li>
+        <li>test_run_id</li>
+        <li>presubmit_run_owner</li>
+      </ul>
+    </p>
+  </Typography>;
+};
+
+const ClustersTableFilter = ({
+  failureFilter,
+  setFailureFilter,
+}: Props) => {
+  const [filter, setFilter] = useState<string>(failureFilter);
+  const [filterHelpAnchorEl, setFilterHelpAnchorEl] = useState<HTMLButtonElement | null>(null);
+
+  return (
+    <Grid container item xs={12} columnGap={2} data-testid="clusters_table_filter">
+      <Grid item xs={12}>
+        <FormControl fullWidth data-testid="failure_filter">
+          <TextField
+            id="failure_filter"
+            value={filter}
+            variant='outlined'
+            label='Filter failures'
+            placeholder='Filter test failures used in clusters'
+            onChange={(e) => setFilter(e.target.value)}
+            onKeyUp={(e) => {
+              if (e.key == 'Enter') {
+                setFailureFilter(filter);
+              }
+            }}
+            onBlur={()=> setFailureFilter(filter)}
+            InputProps={{
+              startAdornment: (
+                <InputAdornment position="start">
+                  <Search />
+                </InputAdornment>),
+              endAdornment: (
+                <InputAdornment position="end">
+                  <IconButton
+                    aria-label="toggle search help"
+                    edge="end"
+                    onClick={(e) => setFilterHelpAnchorEl(e.currentTarget)}
+                  >
+                    {<HelpOutline />}
+                  </IconButton>
+                </InputAdornment>),
+            }}
+            inputProps={{
+              'data-testid': 'failure_filter_input',
+            }}>
+          </TextField>
+        </FormControl>
+        <Popover open={Boolean(filterHelpAnchorEl)} anchorEl={filterHelpAnchorEl} onClose={() => setFilterHelpAnchorEl(null)} anchorOrigin={{
+          vertical: 'bottom',
+          horizontal: 'right',
+        }} transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}>
+          <FilterHelp />
+        </Popover>
+      </Grid>
+    </Grid>
+  );
+};
+
+export default ClustersTableFilter;
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table_head/clusters_table_head.test.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table_head/clusters_table_head.test.tsx
new file mode 100644
index 0000000..d320f61
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table_head/clusters_table_head.test.tsx
@@ -0,0 +1,42 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import { identityFunction } from '../../../testing_tools/functions';
+import ClustersTableHead from './clusters_table_head';
+
+describe('Test ClustersTableHead', () => {
+  it('should display sortable table head', async () => {
+    render(
+        <table>
+          <ClustersTableHead
+            isAscending={false}
+            toggleSort={identityFunction}
+            sortMetric={'critical_failures_exonerated'}/>
+        </table>,
+    );
+
+    await (screen.findByTestId('clusters_table_head'));
+
+    expect(screen.getByText('User Cls Failed Presubmit')).toBeInTheDocument();
+    expect(screen.getByText('Presubmit-Blocking Failures Exonerated')).toBeInTheDocument();
+    expect(screen.getByText('Total Failures')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table_head/clusters_table_head.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table_head/clusters_table_head.tsx
new file mode 100644
index 0000000..c4e0765
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table_head/clusters_table_head.tsx
@@ -0,0 +1,73 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+import TableSortLabel from '@mui/material/TableSortLabel';
+import { SortableMetricName } from '../../../services/cluster';
+
+interface Props {
+    toggleSort: (metric: SortableMetricName) => void,
+    sortMetric: SortableMetricName,
+    isAscending: boolean,
+}
+
+const ClustersTableHead = ({
+  toggleSort,
+  sortMetric,
+  isAscending,
+}: Props) => {
+  return (
+    <TableHead data-testid="clusters_table_head">
+      <TableRow>
+        <TableCell>Cluster</TableCell>
+        <TableCell sx={{ width: '150px' }}>Bug</TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'presubmit_rejects' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer', width: '100px' }}>
+          <TableSortLabel
+            aria-label="Sort by User CLs failed Presubmit"
+            active={sortMetric === 'presubmit_rejects'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('presubmit_rejects')}>
+              User Cls Failed Presubmit
+          </TableSortLabel>
+        </TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'critical_failures_exonerated' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer', width: '100px' }}>
+          <TableSortLabel
+            active={sortMetric === 'critical_failures_exonerated'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('critical_failures_exonerated')}>
+              Presubmit-Blocking Failures Exonerated
+          </TableSortLabel>
+        </TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'failures' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer', width: '100px' }}>
+          <TableSortLabel
+            active={sortMetric === 'failures'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('failures')}>
+              Total Failures
+          </TableSortLabel>
+        </TableCell>
+      </TableRow>
+    </TableHead>
+  );
+};
+
+export default ClustersTableHead;
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table_row/clusters_table_row.test.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table_row/clusters_table_row.test.tsx
new file mode 100644
index 0000000..8a0b53a3
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table_row/clusters_table_row.test.tsx
@@ -0,0 +1,67 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  screen,
+} from '@testing-library/react';
+
+import {
+  getMockRuleClusterSummary,
+  getMockSuggestedClusterSummary,
+} from '../../../testing_tools/mocks/cluster_mock';
+import { renderWithRouterAndClient } from '../../../testing_tools/libs/mock_router';
+import ClustersTableRow from './clusters_table_row';
+
+describe('Test ClustersTableRow component', () => {
+  it('given a rule cluster', async () => {
+    const mockCluster = getMockRuleClusterSummary('abcdef1234567890abcdef1234567890');
+    renderWithRouterAndClient(
+        <table>
+          <tbody>
+            <ClustersTableRow
+              project='testproject'
+              cluster={mockCluster}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByText(mockCluster.title);
+
+    expect(screen.getByText(mockCluster.bug?.linkText || '')).toBeInTheDocument();
+    expect(screen.getByText(mockCluster.presubmitRejects || '0')).toBeInTheDocument();
+    expect(screen.getByText(mockCluster.criticalFailuresExonerated || '0')).toBeInTheDocument();
+    expect(screen.getByText(mockCluster.failures || '0')).toBeInTheDocument();
+  });
+
+  it('given a suggested cluster', async () => {
+    const mockCluster = getMockSuggestedClusterSummary('abcdef1234567890abcdef1234567890');
+    renderWithRouterAndClient(
+        <table>
+          <tbody>
+            <ClustersTableRow
+              project='testproject'
+              cluster={mockCluster}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByText(mockCluster.title);
+
+    expect(screen.getByText(mockCluster.presubmitRejects || '0')).toBeInTheDocument();
+    expect(screen.getByText(mockCluster.criticalFailuresExonerated || '0')).toBeInTheDocument();
+    expect(screen.getByText(mockCluster.failures || '0')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/clusters_table/clusters_table_row/clusters_table_row.tsx b/analysis/frontend/ui/src/components/clusters_table/clusters_table_row/clusters_table_row.tsx
new file mode 100644
index 0000000..14519af
--- /dev/null
+++ b/analysis/frontend/ui/src/components/clusters_table/clusters_table_row/clusters_table_row.tsx
@@ -0,0 +1,50 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Link as RouterLink } from 'react-router-dom';
+import Link from '@mui/material/Link';
+import TableCell from '@mui/material/TableCell';
+import TableRow from '@mui/material/TableRow';
+
+import { linkToCluster } from '../../../tools/urlHandling/links';
+import { ClusterSummary } from '../../../services/cluster';
+
+interface Props {
+  project: string,
+  cluster: ClusterSummary,
+}
+
+const ClustersTableRow = ({
+  project,
+  cluster,
+}: Props) => {
+  return (
+    <TableRow>
+      <TableCell data-testid="clusters_table_title">
+        <Link component={RouterLink} to={linkToCluster(project, cluster.clusterId)} underline="hover">{cluster.title}</Link>
+      </TableCell>
+      <TableCell data-testid="clusters_table_bug">
+        {
+          cluster.bug &&
+            <Link href={cluster.bug.url} underline="hover">{cluster.bug.linkText}</Link>
+        }
+      </TableCell>
+      <TableCell className="number">{cluster.presubmitRejects || '0'}</TableCell>
+      <TableCell className="number">{cluster.criticalFailuresExonerated || '0'}</TableCell>
+      <TableCell className="number">{cluster.failures || '0'}</TableCell>
+    </TableRow>
+  );
+};
+
+export default ClustersTableRow;
diff --git a/analysis/frontend/ui/src/components/codeblock/codeblock.css b/analysis/frontend/ui/src/components/codeblock/codeblock.css
new file mode 100644
index 0000000..258b6fb
--- /dev/null
+++ b/analysis/frontend/ui/src/components/codeblock/codeblock.css
@@ -0,0 +1,26 @@
+/**
+* Copyright 2022 The LUCI Authors.
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*      http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+.codeblock {
+  display: inline-block;
+  padding: 20px 14px;
+  margin: 0;
+  font-family: monospace;
+  overflow-wrap: anywhere;
+  white-space: pre-wrap;
+  background-color: var(--block-background-color);
+  border: solid 1px var(--divider-color);
+}
diff --git a/analysis/frontend/ui/src/components/codeblock/codeblock.test.tsx b/analysis/frontend/ui/src/components/codeblock/codeblock.test.tsx
new file mode 100644
index 0000000..d08dba3
--- /dev/null
+++ b/analysis/frontend/ui/src/components/codeblock/codeblock.test.tsx
@@ -0,0 +1,31 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+  waitFor,
+} from '@testing-library/react';
+
+import CodeBlock from './codeblock';
+
+describe('Test CodeBlock component', () => {
+  it('given a piece of code, then should display it in a block', async () => {
+    render(<CodeBlock code='int x = 0'/>);
+    await waitFor(() => screen.getByTestId('codeblock'));
+    expect(screen.getByTestId('codeblock')).toHaveTextContent('int x = 0');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/codeblock/codeblock.tsx b/analysis/frontend/ui/src/components/codeblock/codeblock.tsx
new file mode 100644
index 0000000..433684c
--- /dev/null
+++ b/analysis/frontend/ui/src/components/codeblock/codeblock.tsx
@@ -0,0 +1,28 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './codeblock.css';
+interface Props {
+    code: string | undefined;
+}
+
+const CodeBlock = ({ code }: Props) => {
+  return (
+    <pre className="codeblock" data-testid="codeblock">
+      {code}
+    </pre>
+  );
+};
+
+export default CodeBlock;
diff --git a/analysis/frontend/ui/src/components/confirm_dialog/confirm_dialog.test.tsx b/analysis/frontend/ui/src/components/confirm_dialog/confirm_dialog.test.tsx
new file mode 100644
index 0000000..86b8db3
--- /dev/null
+++ b/analysis/frontend/ui/src/components/confirm_dialog/confirm_dialog.test.tsx
@@ -0,0 +1,44 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import { identityFunction } from '../../testing_tools/functions';
+import ConfirmDialog from './confirm_dialog';
+
+describe('Test ConfirmDialog component', () => {
+  it('Given no message, should display title only', async () => {
+    render(<ConfirmDialog
+      onCancel={identityFunction}
+      onConfirm={identityFunction}
+      open/>);
+    await screen.findByText('Are you sure?');
+    expect(screen.getByText('Are you sure?')).toBeInTheDocument();
+  });
+
+  it('Given a message, then should display it', async () => {
+    render(<ConfirmDialog
+      onCancel={identityFunction}
+      onConfirm={identityFunction}
+      message="Test message"
+      open/>);
+    await screen.findByText('Test message');
+    expect(screen.getByText('Test message')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/confirm_dialog/confirm_dialog.tsx b/analysis/frontend/ui/src/components/confirm_dialog/confirm_dialog.tsx
new file mode 100644
index 0000000..95f2d83
--- /dev/null
+++ b/analysis/frontend/ui/src/components/confirm_dialog/confirm_dialog.tsx
@@ -0,0 +1,54 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Dialog from '@mui/material/Dialog';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContent from '@mui/material/DialogContent';
+import Typography from '@mui/material/Typography';
+import DialogActions from '@mui/material/DialogActions';
+import Button from '@mui/material/Button';
+
+type HandleFunction = () => void;
+
+interface Props {
+    message?: string;
+    open: boolean;
+    onConfirm: HandleFunction;
+    onCancel: HandleFunction;
+}
+
+const ConfirmDialog = ({
+  message = '',
+  open,
+  onConfirm,
+  onCancel,
+}: Props) => {
+  return (
+    <Dialog open={open} maxWidth="xs" fullWidth>
+      <DialogTitle>Are you sure?</DialogTitle>
+      {message && (
+        <DialogContent>
+          <Typography>{message}</Typography>
+        </DialogContent>
+      )
+      }
+      <DialogActions>
+        <Button variant="outlined" onClick={onCancel} data-testid="confirm-dialog-cancel">Cancel</Button>
+        <Button variant="contained" onClick={onConfirm} data-testid="confirm-dialog-confirm">Confirm</Button>
+      </DialogActions>
+    </Dialog>
+  );
+};
+
+export default ConfirmDialog;
diff --git a/analysis/frontend/ui/src/components/error_alert/error_alert.test.tsx b/analysis/frontend/ui/src/components/error_alert/error_alert.test.tsx
new file mode 100644
index 0000000..acbd62a
--- /dev/null
+++ b/analysis/frontend/ui/src/components/error_alert/error_alert.test.tsx
@@ -0,0 +1,34 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import ErrorAlert from './error_alert';
+
+describe('Test ErrorAlert component', () => {
+  it('given a title and text, then should display them.', async () => {
+    render(<ErrorAlert
+      errorTitle="Test error title"
+      errorText="Test error text"
+      showError={true}/>);
+    await screen.findByText('Test error title');
+    expect(screen.getByText('Test error title')).toBeInTheDocument();
+    expect(screen.getByText('Test error text')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/error_alert/error_alert.tsx b/analysis/frontend/ui/src/components/error_alert/error_alert.tsx
new file mode 100644
index 0000000..2f32052
--- /dev/null
+++ b/analysis/frontend/ui/src/components/error_alert/error_alert.tsx
@@ -0,0 +1,51 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Close from '@mui/icons-material/Close';
+import Alert from '@mui/material/Alert';
+import AlertTitle from '@mui/material/AlertTitle';
+import Collapse from '@mui/material/Collapse';
+import IconButton from '@mui/material/IconButton';
+
+interface Props {
+    errorTitle: string;
+    errorText: string;
+    showError: boolean;
+    onErrorClose?: () => void;
+}
+
+const ErrorAlert = ({
+  errorTitle,
+  errorText,
+  showError,
+  onErrorClose,
+}: Props) => (
+  <Collapse in={showError}>
+    <Alert
+      severity="error"
+      action={<IconButton
+        aria-label="close"
+        color="inherit"
+        size="small"
+        onClick={onErrorClose}>
+        <Close fontSize="inherit" />
+      </IconButton>}
+      sx={{ mb: 2 }}>
+      <AlertTitle>{errorTitle}</AlertTitle>
+      {errorText}
+    </Alert>
+  </Collapse>
+);
+
+export default ErrorAlert;
diff --git a/analysis/frontend/ui/src/components/error_snackbar/feedback_snackbar.test.tsx b/analysis/frontend/ui/src/components/error_snackbar/feedback_snackbar.test.tsx
new file mode 100644
index 0000000..62dddba
--- /dev/null
+++ b/analysis/frontend/ui/src/components/error_snackbar/feedback_snackbar.test.tsx
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import {
+  Snack,
+  SnackbarContext,
+} from '../../context/snackbar_context';
+import { identityFunction } from '../../testing_tools/functions';
+import FeedbackSnackbar from './feedback_snackbar';
+
+describe('Test ErrorSnackbar component', () => {
+  it('given an error text, should display in a snackbar', async () => {
+    const snack: Snack = {
+      open: true,
+      message: 'Failed to load issue',
+      severity: 'error',
+    };
+    render(
+        <SnackbarContext.Provider value={{
+          snack: snack,
+          setSnack: identityFunction,
+        }}>
+          <FeedbackSnackbar />
+        </SnackbarContext.Provider>,
+    );
+
+    await screen.findByTestId('snackbar');
+
+    expect(screen.getByText('Failed to load issue')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/error_snackbar/feedback_snackbar.tsx b/analysis/frontend/ui/src/components/error_snackbar/feedback_snackbar.tsx
new file mode 100644
index 0000000..b00e50f
--- /dev/null
+++ b/analysis/frontend/ui/src/components/error_snackbar/feedback_snackbar.tsx
@@ -0,0 +1,46 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useContext } from 'react';
+
+import Alert from '@mui/material/Alert';
+import Snackbar from '@mui/material/Snackbar';
+
+import {
+  SnackbarContext,
+  snackContextDefaultState,
+} from '../../context/snackbar_context';
+
+const FeedbackSnackbar = () => {
+  const { snack, setSnack } = useContext(SnackbarContext);
+
+  const handleClose = () => {
+    setSnack(snackContextDefaultState);
+  };
+
+  return (
+    <Snackbar
+      data-testid="snackbar"
+      open={snack.open}
+      autoHideDuration={6000}
+      anchorOrigin={{ horizontal: 'center', vertical: 'bottom' }}
+      onClose={handleClose}>
+      <Alert onClose={handleClose} severity={snack.severity} sx={{ width: '100%' }}>
+        {snack.message}
+      </Alert>
+    </Snackbar>
+  );
+};
+
+export default FeedbackSnackbar;
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table.test.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table.test.tsx
new file mode 100644
index 0000000..c6a1708
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table.test.tsx
@@ -0,0 +1,208 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  fireEvent,
+  screen,
+} from '@testing-library/react';
+
+import { renderWithRouterAndClient } from '../../testing_tools/libs/mock_router';
+import {
+  createDefaultMockFailures,
+  newMockFailure,
+} from '../../testing_tools/mocks/failures_mock';
+import { mockFetchAuthState } from '../../testing_tools/mocks/authstate_mock';
+import { FailureFilters } from '../../tools/failures_tools';
+import { mockQueryClusterFailures } from '../../testing_tools/mocks/cluster_mock';
+import FailuresTable from './failures_table';
+
+describe('Test FailureTable component', () => {
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given cluster failures, should group and display them', async () => {
+    mockFetchAuthState();
+    const mockFailures = createDefaultMockFailures();
+    mockQueryClusterFailures('projects/chrome/clusters/rules/rule-123345/failures', mockFailures);
+
+    renderWithRouterAndClient(
+        <FailuresTable
+          clusterAlgorithm="rules"
+          clusterId="rule-123345"
+          project="chrome"/>,
+    );
+
+    await screen.findByRole('table');
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    expect(screen.getByText(mockFailures[0].testId!)).toBeInTheDocument();
+  });
+
+  it('when clicking a sortable column then should modify groups order', async () => {
+    mockFetchAuthState();
+    const mockFailures = [
+      newMockFailure().withTestId('group1').build(),
+      newMockFailure().withTestId('group1').build(),
+      newMockFailure().withTestId('group1').build(),
+      newMockFailure().withTestId('group2').build(),
+      newMockFailure().withTestId('group3').build(),
+      newMockFailure().withTestId('group3').build(),
+      newMockFailure().withTestId('group3').build(),
+      newMockFailure().withTestId('group3').build(),
+    ];
+    mockQueryClusterFailures('projects/chrome/clusters/rules/rule-123345/failures', mockFailures);
+
+    renderWithRouterAndClient(
+        <FailuresTable
+          clusterAlgorithm="rules"
+          clusterId="rule-123345"
+          project="chrome"/>,
+    );
+
+    await screen.findByRole('table');
+
+    let allGroupCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(allGroupCells.length).toBe(3);
+    expect(allGroupCells[0]).toHaveTextContent('group1');
+    expect(allGroupCells[1]).toHaveTextContent('group2');
+    expect(allGroupCells[2]).toHaveTextContent('group3');
+
+    await fireEvent.click(screen.getByText('Total Failures'));
+
+    allGroupCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(allGroupCells.length).toBe(3);
+    expect(allGroupCells[0]).toHaveTextContent('group3');
+    expect(allGroupCells[1]).toHaveTextContent('group1');
+    expect(allGroupCells[2]).toHaveTextContent('group2');
+  });
+
+  it('when expanding then should show child groups', async () => {
+    mockFetchAuthState();
+    const mockFailures = [
+      newMockFailure().withTestId('group1').build(),
+      newMockFailure().withTestId('group1').build(),
+      newMockFailure().withTestId('group1').build(),
+    ];
+    mockQueryClusterFailures('projects/chrome/clusters/rules/rule-123345/failures', mockFailures);
+
+    renderWithRouterAndClient(
+        <FailuresTable
+          clusterAlgorithm="rules"
+          clusterId="rule-123345"
+          project="chrome"/>,
+    );
+
+    await screen.findByRole('table');
+
+    let allGroupCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(allGroupCells.length).toBe(1);
+    expect(allGroupCells[0]).toHaveTextContent('group1');
+
+    await fireEvent.click(screen.getByLabelText('Expand group'));
+
+    allGroupCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(allGroupCells.length).toBe(4);
+  });
+
+  it('when filtering by failure type then should display matching groups', async () => {
+    mockFetchAuthState();
+    const mockFailures = [
+      newMockFailure().withoutPresubmit().withTestId('group1').build(),
+      newMockFailure().withTestId('group2').build(),
+      newMockFailure().withTestId('group3').build(),
+    ];
+    mockQueryClusterFailures('projects/chrome/clusters/rules/rule-123345/failures', mockFailures);
+
+    renderWithRouterAndClient(
+        <FailuresTable
+          clusterAlgorithm="rules"
+          clusterId="rule-123345"
+          project="chrome"/>,
+    );
+
+    await screen.findByRole('table');
+
+    let allGroupCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(allGroupCells.length).toBe(3);
+    expect(allGroupCells[0]).toHaveTextContent('group1');
+    expect(allGroupCells[1]).toHaveTextContent('group2');
+    expect(allGroupCells[2]).toHaveTextContent('group3');
+
+    await fireEvent.change(screen.getByTestId('failure_filter_input'), { target: { value: FailureFilters[1] } });
+
+    allGroupCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(allGroupCells.length).toBe(2);
+    expect(allGroupCells[0]).toHaveTextContent('group2');
+    expect(allGroupCells[1]).toHaveTextContent('group3');
+  });
+
+  it('when filtering with impact then should recalculate impact', async () => {
+    mockFetchAuthState();
+    const mockFailures = [
+      newMockFailure().withoutPresubmit().withTestId('group1').build(),
+      newMockFailure().withTestId('group1').build(),
+    ];
+    mockQueryClusterFailures('projects/chrome/clusters/rules/rule-123345/failures', mockFailures);
+
+    renderWithRouterAndClient(
+        <FailuresTable
+          clusterAlgorithm="rules"
+          clusterId="rule-123345"
+          project="chrome"/>,
+    );
+
+    await screen.findByRole('table');
+    await fireEvent.change(screen.getByTestId('impact_filter_input'), { target: { value: 'Without Any Retries' } });
+
+    let presubmitRejects = screen.getByTestId('failure_table_group_presubmitrejects');
+    expect(presubmitRejects).toHaveTextContent('1');
+
+    await fireEvent.change(screen.getByTestId('impact_filter_input'), { target: { value: 'Actual Impact' } });
+
+    presubmitRejects = screen.getByTestId('failure_table_group_presubmitrejects');
+    expect(presubmitRejects).toHaveTextContent('0');
+  });
+
+  it('when grouping by variants then should modify displayed tree', async () => {
+    mockFetchAuthState();
+    const mockFailures = [
+      newMockFailure().withVariantGroups('v1', 'a').withTestId('group1').build(),
+      newMockFailure().withVariantGroups('v1', 'a').withTestId('group1').build(),
+      newMockFailure().withVariantGroups('v1', 'b').withTestId('group1').build(),
+      newMockFailure().withVariantGroups('v1', 'b').withTestId('group1').build(),
+    ];
+    mockQueryClusterFailures('projects/chrome/clusters/rules/rule-123345/failures', mockFailures);
+
+    renderWithRouterAndClient(
+        <FailuresTable
+          clusterAlgorithm="rules"
+          clusterId="rule-123345"
+          project="chrome"/>,
+    );
+
+    await screen.findByRole('table');
+    await fireEvent.change(screen.getByTestId('group_by_input'), { target: { value: 'v1' } });
+
+    const groupedCells = screen.getAllByTestId('failures_table_group_cell');
+    expect(groupedCells.length).toBe(2);
+
+    expect(groupedCells[0]).toHaveTextContent('a');
+    expect(groupedCells[1]).toHaveTextContent('b');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table.tsx
new file mode 100644
index 0000000..af46a5b
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table.tsx
@@ -0,0 +1,198 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  useEffect,
+  useState,
+} from 'react';
+import { useQuery } from 'react-query';
+import {
+  useUpdateEffect,
+} from 'react-use';
+
+import CircularProgress from '@mui/material/CircularProgress';
+import Grid from '@mui/material/Grid';
+import { SelectChangeEvent } from '@mui/material/Select';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+
+import { getClustersService } from '../../services/cluster';
+import {
+  countAndSortFailures,
+  countDistictVariantValues,
+  defaultFailureFilter,
+  defaultImpactFilter,
+  FailureFilter,
+  FailureFilters,
+  FailureGroup,
+  VariantGroup,
+  groupAndCountFailures,
+  ImpactFilter,
+  ImpactFilters,
+  MetricName,
+  sortFailureGroups,
+} from '../../tools/failures_tools';
+import ErrorAlert from '../error_alert/error_alert';
+import FailuresTableFilter from './failures_table_filter/failures_table_filter';
+import FailuresTableGroup from './failures_table_group/failures_table_group';
+import FailuresTableHead from './failures_table_head/failures_table_head';
+
+interface Props {
+    project: string;
+    clusterAlgorithm: string;
+    clusterId: string;
+}
+
+const FailuresTable = ({
+  project,
+  clusterAlgorithm,
+  clusterId,
+}: Props) => {
+  const [groups, setGroups] = useState<FailureGroup[]>([]);
+  const [variantGroups, setVariantGroups] = useState<VariantGroup[]>([]);
+
+  const [failureFilter, setFailureFilter] = useState<FailureFilter>(defaultFailureFilter);
+  const [impactFilter, setImpactFilter] = useState<ImpactFilter>(defaultImpactFilter);
+  const [selectedVariantGroups, setSelectedVariantGroups] = useState<string[]>([]);
+
+  const [sortMetric, setCurrentSortMetric] = useState<MetricName>('latestFailureTime');
+  const [isAscending, setIsAscending] = useState(false);
+
+  const {
+    isLoading,
+    isError,
+    data: failures,
+    error,
+  } = useQuery(
+      ['clusterFailures', project, clusterAlgorithm, clusterId],
+      async () => {
+        const service = getClustersService();
+        const response = await service.queryClusterFailures({
+          parent: `projects/${project}/clusters/${clusterAlgorithm}/${clusterId}/failures`,
+        });
+        return response.failures || [];
+      });
+
+  useEffect( () => {
+    if (failures) {
+      setVariantGroups(countDistictVariantValues(failures));
+    }
+  }, [failures]);
+
+  useUpdateEffect(() => {
+    setGroups(sortFailureGroups(groups, sortMetric, isAscending));
+  }, [sortMetric, isAscending]);
+
+  useUpdateEffect(() => {
+    setGroups(countAndSortFailures(groups, impactFilter));
+  }, [impactFilter]);
+
+  useUpdateEffect(() => {
+    groupCountAndSortFailures();
+  }, [failureFilter]);
+
+  useUpdateEffect(() => {
+    groupCountAndSortFailures();
+  }, [variantGroups]);
+
+  useUpdateEffect(() => {
+    const variantGroupsClone = [...variantGroups];
+    variantGroupsClone.forEach((variantGroup) => {
+      variantGroup.isSelected = selectedVariantGroups.includes(variantGroup.key);
+    });
+    setVariantGroups(variantGroupsClone);
+  }, [selectedVariantGroups]);
+
+  const groupCountAndSortFailures = () => {
+    if (failures) {
+      let updatedGroups = groupAndCountFailures(failures, variantGroups, failureFilter);
+      updatedGroups = countAndSortFailures(updatedGroups, impactFilter);
+      setGroups(sortFailureGroups(updatedGroups, sortMetric, isAscending));
+    }
+  };
+
+  const onImpactFilterChanged = (event: SelectChangeEvent) => {
+    setImpactFilter(ImpactFilters.filter((filter) => filter.name === event.target.value)?.[0] || ImpactFilters[1]);
+  };
+
+  const onFailureFilterChanged = (event: SelectChangeEvent) => {
+    setFailureFilter((event.target.value as FailureFilter) || FailureFilters[0]);
+  };
+
+  const handleVariantsChange = (event: SelectChangeEvent<typeof selectedVariantGroups>) => {
+    const value = event.target.value;
+    setSelectedVariantGroups(typeof value === 'string' ? value.split(',') : value);
+  };
+
+  const toggleSort = (metric: MetricName) => {
+    if (metric === sortMetric) {
+      setIsAscending(!isAscending);
+    } else {
+      setCurrentSortMetric(metric);
+      setIsAscending(false);
+    }
+  };
+
+  if (isLoading) {
+    return (
+      <Grid container item alignItems="center" justifyContent="center">
+        <CircularProgress />
+      </Grid>
+    );
+  }
+
+  if (isError || !failures) {
+    return (
+      <ErrorAlert
+        errorTitle="Failed to load failures"
+        errorText={`Loading cluster failures failed due to: ${error}`}
+        showError/>
+    );
+  }
+
+  return (
+    <Grid container columnGap={2} rowGap={2}>
+      <FailuresTableFilter
+        failureFilter={failureFilter}
+        onFailureFilterChanged={onFailureFilterChanged}
+        impactFilter={impactFilter}
+        onImpactFilterChanged={onImpactFilterChanged}
+        variantGroups={variantGroups}
+        selectedVariantGroups={selectedVariantGroups}
+        handleVariantGroupsChange={handleVariantsChange}/>
+      <Grid item xs={12}>
+        <Table size="small">
+          <FailuresTableHead
+            toggleSort={toggleSort}
+            sortMetric={sortMetric}
+            isAscending={isAscending}/>
+          <TableBody>
+            {
+              groups.map((group) => (
+                <FailuresTableGroup
+                  project={project}
+                  parentKeys={[]}
+                  key={group.id}
+                  group={group}
+                  variantGroups={variantGroups}/>
+              ))
+            }
+          </TableBody>
+        </Table>
+      </Grid>
+    </Grid>
+  );
+};
+
+export default FailuresTable;
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_filter/failures_table_filter.test.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_filter/failures_table_filter.test.tsx
new file mode 100644
index 0000000..e3aa765
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_filter/failures_table_filter.test.tsx
@@ -0,0 +1,65 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import { identityFunction } from '../../../testing_tools/functions';
+import { createMockVariantGroups } from '../../../testing_tools/mocks/failures_mock';
+import { defaultImpactFilter } from '../../../tools/failures_tools';
+import FailuresTableFilter from './failures_table_filter';
+
+describe('Test FailureTableFilter component', () => {
+  it('should display 3 filters.', async () => {
+    render(
+        <FailuresTableFilter
+          failureFilter="All Failures"
+          onFailureFilterChanged={identityFunction}
+          impactFilter={defaultImpactFilter}
+          onImpactFilterChanged={identityFunction}
+          variantGroups={createMockVariantGroups()}
+          selectedVariantGroups={[]}
+          handleVariantGroupsChange={identityFunction}/>,
+    );
+
+    await screen.findByTestId('failure_table_filter');
+
+    expect(screen.getByTestId('failure_filter')).toBeInTheDocument();
+    expect(screen.getByTestId('impact_filter')).toBeInTheDocument();
+    expect(screen.getByTestId('group_by')).toBeInTheDocument();
+  });
+
+  it('given non default selected values then should display them', async () => {
+    render(
+        <FailuresTableFilter
+          failureFilter="All Failures"
+          onFailureFilterChanged={identityFunction}
+          impactFilter={defaultImpactFilter}
+          onImpactFilterChanged={identityFunction}
+          variantGroups={createMockVariantGroups()}
+          selectedVariantGroups={['v1', 'v2']}
+          handleVariantGroupsChange={identityFunction}/>,
+    );
+
+    await screen.findByTestId('failure_table_filter');
+
+    expect(screen.getByTestId('failure_filter_input')).toHaveValue('All Failures');
+    expect(screen.getByTestId('impact_filter_input')).toHaveValue(defaultImpactFilter.name);
+    expect(screen.getByTestId('group_by_input')).toHaveValue('v1,v2');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_filter/failures_table_filter.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_filter/failures_table_filter.tsx
new file mode 100644
index 0000000..ad7c685
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_filter/failures_table_filter.tsx
@@ -0,0 +1,123 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import FormControl from '@mui/material/FormControl';
+import Grid from '@mui/material/Grid';
+import InputLabel from '@mui/material/InputLabel';
+import MenuItem from '@mui/material/MenuItem';
+import OutlinedInput from '@mui/material/OutlinedInput';
+import Select, { SelectChangeEvent } from '@mui/material/Select';
+
+import {
+  FailureFilter,
+  FailureFilters,
+  VariantGroup,
+  ImpactFilter,
+  ImpactFilters,
+} from '../../../tools/failures_tools';
+
+interface Props {
+    failureFilter: FailureFilter,
+    onFailureFilterChanged: (event: SelectChangeEvent) => void,
+    impactFilter: ImpactFilter,
+    onImpactFilterChanged: (event: SelectChangeEvent) => void,
+    variantGroups: VariantGroup[],
+    selectedVariantGroups: string[],
+    handleVariantGroupsChange: (event: SelectChangeEvent<string[]>) => void,
+}
+
+const FailuresTableFilter = ({
+  failureFilter,
+  onFailureFilterChanged,
+  impactFilter,
+  onImpactFilterChanged,
+  variantGroups,
+  selectedVariantGroups,
+  handleVariantGroupsChange,
+}: Props) => {
+  return (
+    <>
+      <Grid container item xs={12} columnGap={2} data-testid="failure_table_filter">
+        <Grid item xs={2}>
+          <FormControl fullWidth data-testid="failure_filter">
+            <InputLabel id="failure_filter_label">Failure filter</InputLabel>
+            <Select
+              labelId="failure_filter_label"
+              id="impact_filter"
+              value={failureFilter}
+              label="Failure filter"
+              onChange={onFailureFilterChanged}
+              inputProps={{ 'data-testid': 'failure_filter_input' }}>
+              {
+                FailureFilters.map((filter) => (
+                  <MenuItem key={filter} value={filter}>{filter}</MenuItem>
+                ))
+              }
+            </Select>
+          </FormControl>
+        </Grid>
+        <Grid item xs={2}>
+          <FormControl fullWidth data-testid="impact_filter">
+            <InputLabel id="impact_filter_label">Impact filter</InputLabel>
+            <Select
+              labelId="impact_filter_label"
+              id="impact_filter"
+              value={impactFilter.name}
+              label="Impact filter"
+              onChange={onImpactFilterChanged}
+              inputProps={{ 'data-testid': 'impact_filter_input' }}>
+              {
+                ImpactFilters.map((filter) => (
+                  <MenuItem key={filter.name} value={filter.name}>{filter.name}</MenuItem>
+                ))
+              }
+            </Select>
+          </FormControl>
+        </Grid>
+        <Grid item xs={6}>
+          <FormControl fullWidth data-testid="group_by">
+            <InputLabel id="group_by_label">Group by</InputLabel>
+            <Select
+              labelId="group_by_label"
+              id="group_by"
+              multiple
+              value={selectedVariantGroups}
+              onChange={handleVariantGroupsChange}
+              input={<OutlinedInput id="group_by_select" label="Group by" />}
+              renderValue={(selected) => (
+                <Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
+                  {selected.map((value) => (
+                    <Chip key={value} label={value} />
+                  ))}
+                </Box>
+              )}
+              inputProps={{ 'data-testid': 'group_by_input' }}>
+              {variantGroups.map((variantGroup) => (
+                <MenuItem
+                  key={variantGroup.key}
+                  value={variantGroup.key}>
+                  {variantGroup.key} ({variantGroup.values.length})
+                </MenuItem>
+              ))}
+            </Select>
+          </FormControl>
+        </Grid>
+      </Grid>
+    </>
+  );
+};
+
+export default FailuresTableFilter;
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_group.test.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_group.test.tsx
new file mode 100644
index 0000000..ff2723d
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_group.test.tsx
@@ -0,0 +1,126 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  fireEvent,
+  render,
+  screen,
+} from '@testing-library/react';
+
+import {
+  GroupKey,
+} from '../../../tools/failures_tools';
+
+import {
+  createDefaultMockFailureGroup,
+  createDefaultMockFailureGroupWithChildren,
+  createMockVariantGroups,
+} from '../../../testing_tools/mocks/failures_mock';
+import FailuresTableGroup from './failures_table_group';
+
+describe('Test FailureTableGroup component', () => {
+  it('given a group without children then should display 1 row', async () => {
+    const mockGroup = createDefaultMockFailureGroup();
+    render(
+        <table>
+          <tbody>
+            <FailuresTableGroup
+              project='testproject'
+              group={mockGroup}
+              variantGroups={createMockVariantGroups()}/>
+          </tbody>
+        </table>,
+    );
+
+    await (screen.findByText(mockGroup.key.value));
+
+    expect(screen.getByText(mockGroup.key.value)).toBeInTheDocument();
+  });
+
+  it('given a group with children then should display just the group when not expanded', async () => {
+    const mockGroup = createDefaultMockFailureGroupWithChildren();
+    render(
+        <table>
+          <tbody>
+            <FailuresTableGroup
+              project='testproject'
+              group={mockGroup}
+              variantGroups={createMockVariantGroups()}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByText(mockGroup.key.value);
+
+    expect(screen.getAllByRole('row')).toHaveLength(1);
+  });
+
+  it('given a test name group it should show a test history link', async () => {
+    const mockGroup = createDefaultMockFailureGroupWithChildren();
+    mockGroup.key = { type: 'test', value: 'ninja://package/sometest.Blah?a=1' };
+    const parentKeys : GroupKey[] = [{
+      type: 'variant',
+      key: 'k1',
+      value: 'v1',
+    }, {
+      // Consider a variant with special characters.
+      type: 'variant',
+      key: 'key %+',
+      value: 'value %+',
+    }];
+
+    render(
+        <table>
+          <tbody>
+            <FailuresTableGroup
+              project='testproject'
+              parentKeys={parentKeys}
+              group={mockGroup}
+              variantGroups={createMockVariantGroups()}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByText(mockGroup.key.value);
+
+    expect(screen.getByLabelText('Test history link')).toBeInTheDocument();
+    expect(screen.getByLabelText('Test history link')).toHaveAttribute('href',
+        'https://ci.chromium.org/ui/test/testproject/ninja%3A%2F%2Fpackage%2Fsometest.Blah%3Fa%3D1?q=V%3Ak1%3Dv1%20V%3Akey%2520%2525%252B%3Dvalue%2520%2525%252B');
+    expect(screen.getAllByRole('row')).toHaveLength(1);
+  });
+
+  it('given a group with children then should display all when expanded', async () => {
+    const mockGroup = createDefaultMockFailureGroupWithChildren();
+    render(
+        <table>
+          <tbody>
+            <FailuresTableGroup
+              project='testproject'
+              group={mockGroup}
+              variantGroups={createMockVariantGroups()}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByText(mockGroup.key.value);
+
+    fireEvent.click(screen.getByLabelText('Expand group'));
+
+    await (screen.findByText(mockGroup.children[2].failures));
+
+    expect(screen.getAllByRole('row')).toHaveLength(4);
+  });
+});
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_group.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_group.tsx
new file mode 100644
index 0000000..569d5bf
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_group.tsx
@@ -0,0 +1,71 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// import { nanoid } from 'nanoid';
+import {
+  Fragment,
+  ReactNode,
+} from 'react';
+
+import {
+  FailureGroup,
+  GroupKey,
+  VariantGroup,
+} from '../../../tools/failures_tools';
+import FailuresTableRows from './failures_table_rows/failures_table_rows';
+
+const renderGroup = (
+    project: string,
+    parentKeys: GroupKey[],
+    group: FailureGroup,
+    variantGroups: VariantGroup[],
+): ReactNode => {
+  return (
+    <Fragment>
+      <FailuresTableRows
+        project={project}
+        parentKeys={parentKeys}
+        group={group}
+        variantGroups={variantGroups}>
+        {
+          group.children.map((childGroup) => (
+            <Fragment key={childGroup.id}>
+              {renderGroup(project, [...parentKeys, group.key], childGroup, variantGroups)}
+            </Fragment>
+          ))
+        }
+      </FailuresTableRows>
+    </Fragment>
+  );
+};
+
+interface Props {
+  project: string;
+  parentKeys?: GroupKey[];
+  group: FailureGroup;
+  variantGroups: VariantGroup[];
+}
+
+const FailuresTableGroup = ({
+  project,
+  parentKeys = [],
+  group,
+  variantGroups,
+}: Props) => {
+  return (
+    <>{renderGroup(project, parentKeys, group, variantGroups)}</>
+  );
+};
+
+export default FailuresTableGroup;
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_rows/failures_table_rows.test.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_rows/failures_table_rows.test.tsx
new file mode 100644
index 0000000..9f49c4e
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_rows/failures_table_rows.test.tsx
@@ -0,0 +1,83 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import dayjs from 'dayjs';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import {
+  createMockVariantGroups,
+  newMockFailure,
+  newMockGroup,
+} from '../../../../testing_tools/mocks/failures_mock';
+import FailuresTableRows from './failures_table_rows';
+
+describe('Test FailureTableRows component', () => {
+  it('given a group without children', async () => {
+    const mockGroup = newMockGroup({ type: 'leaf', value: 'testgroup' })
+        .withFailures(2)
+        .withPresubmitRejects(3)
+        .withInvocationFailures(4)
+        .withCriticalFailuresExonerated(5)
+        .build();
+    render(
+        <table>
+          <tbody>
+            <FailuresTableRows
+              project='testproject'
+              group={mockGroup}
+              variantGroups={createMockVariantGroups()}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByText(mockGroup.key.value);
+
+    expect(screen.getByText(mockGroup.presubmitRejects)).toBeInTheDocument();
+    expect(screen.getByText(mockGroup.invocationFailures)).toBeInTheDocument();
+    expect(screen.getByText(mockGroup.criticalFailuresExonerated)).toBeInTheDocument();
+    expect(screen.getByText(mockGroup.failures)).toBeInTheDocument();
+    expect(screen.getByText(dayjs(mockGroup.latestFailureTime).fromNow())).toBeInTheDocument();
+  });
+
+  it('given a group with a failure then should display links and variants', async () => {
+    const mockGroup = newMockGroup({ type: 'leaf', value: 'testgroup' })
+        .withFailure(newMockFailure().build())
+        .withFailures(2)
+        .withPresubmitRejects(3)
+        .withInvocationFailures(4)
+        .withCriticalFailuresExonerated(5)
+        .build();
+    render(
+        <table>
+          <tbody>
+            <FailuresTableRows
+              project='testproject'
+              group={mockGroup}
+              variantGroups={createMockVariantGroups()}/>
+          </tbody>
+        </table>,
+    );
+
+    await screen.findByLabelText('Failure invocation id');
+
+    expect(screen.getByTestId('ungrouped_variants')).toBeInTheDocument();
+    expect(screen.getByLabelText('Presubmit rejects link')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_rows/failures_table_rows.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_rows/failures_table_rows.tsx
new file mode 100644
index 0000000..b2298be
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_group/failures_table_rows/failures_table_rows.tsx
@@ -0,0 +1,173 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import { ReactNode, useState } from 'react';
+
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+import ArrowRightIcon from '@mui/icons-material/ArrowRight';
+import Grid from '@mui/material/Grid';
+import IconButton from '@mui/material/IconButton';
+import Link from '@mui/material/Link';
+import TableCell from '@mui/material/TableCell';
+import TableRow from '@mui/material/TableRow';
+
+import {
+  FailureGroup,
+  GroupKey,
+  VariantGroup,
+} from '../../../../tools/failures_tools';
+import { failureLink, testHistoryLink } from '../../../../tools/urlHandling/links';
+import { DistinctClusterFailure } from '../../../../services/cluster';
+
+interface Props {
+  project: string;
+  parentKeys?: GroupKey[];
+  group: FailureGroup;
+  variantGroups: VariantGroup[];
+  children?: ReactNode;
+}
+
+interface VariantPair {
+  key: string;
+  value: string;
+}
+
+const FailuresTableRows = ({
+  project,
+  parentKeys = [],
+  group,
+  variantGroups,
+  children = null,
+}: Props) => {
+  const [expanded, setExpanded] = useState(false);
+
+  const toggleExpand = () => {
+    setExpanded(!expanded);
+  };
+
+  const ungroupedVariants = (failure: DistinctClusterFailure): VariantPair[] => {
+    const unselectedVariants = variantGroups
+        .filter((v) => !v.isSelected)
+        .map((v) => v.key);
+    const unselectedVariantPairs: (VariantPair|null)[] =
+      unselectedVariants.map((key) => {
+        const value = failure.variant?.def[key];
+        if (value !== undefined) {
+          return { key: key, value: value };
+        }
+        return null;
+      });
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return unselectedVariantPairs.filter((vp) => vp != null).map((vp) => vp!);
+  };
+
+  const query = parentKeys.filter((v) => v.type == 'variant').map((v) => {
+    return 'V:' + encodeURIComponent(v.key || '') + '=' + encodeURIComponent(v.value);
+  }).join(' ');
+
+  return (
+    <>
+      <TableRow>
+        <TableCell
+          key={group.id}
+          sx={{
+            paddingLeft: `${20 * group.level}px`,
+            width: '60%',
+          }}
+          data-testid="failures_table_group_cell"
+        >
+          {group.failure ? (
+            <>
+              <Link
+                aria-label="Failure invocation id"
+                sx={{ mr: 2 }}
+                href={failureLink(group.failure)}
+                target="_blank"
+              >
+                {group.failure.ingestedInvocationId}
+              </Link>
+              <small data-testid="ungrouped_variants">
+                {ungroupedVariants(group.failure)
+                    .map((v) => v && `${v.key}: ${v.value}`)
+                    .filter((v) => v)
+                    .join(', ')}
+              </small>
+            </>
+          ) : (
+            <Grid
+              container
+              justifyContent="start"
+              alignItems="baseline"
+              columnGap={2}
+              flexWrap="nowrap"
+            >
+              <Grid item>
+                <IconButton
+                  aria-label="Expand group"
+                  onClick={() => toggleExpand()}
+                >
+                  {expanded ? <ArrowDropDownIcon /> : <ArrowRightIcon />}
+                </IconButton>
+              </Grid>
+              <Grid item sx={{ overflowWrap: 'anywhere' }}>
+                {/** Place test name or variant value in a separate span to allow better testability */}
+                <span>{group.key.value || 'none'}</span>
+                {group.key.type == 'test' ? (
+                <>
+                  &nbsp;-&nbsp;
+                  <Link
+                    sx={{ display: 'inline-flex' }}
+                    aria-label='Test history link'
+                    href={testHistoryLink(project, group.key.value, query)}
+                    underline='hover'
+                    target="_blank">
+                      History
+                  </Link>
+                </>) : null}
+              </Grid>
+            </Grid>
+          )}
+        </TableCell>
+        <TableCell data-testid="failure_table_group_presubmitrejects">
+          {group.failure ? (
+            <>
+              {group.failure.presubmitRun ? (
+                <Link
+                  aria-label="Presubmit rejects link"
+                  href={`https://luci-change-verifier.appspot.com/ui/run/${group.failure.presubmitRun.presubmitRunId.id}`}
+                  target="_blank"
+                >
+                  {group.presubmitRejects}
+                </Link>
+              ) : (
+                '-'
+              )}
+            </>
+          ) : (
+            group.presubmitRejects
+          )}
+        </TableCell>
+        <TableCell className="number">{group.invocationFailures}</TableCell>
+        <TableCell className="number">{group.criticalFailuresExonerated}</TableCell>
+        <TableCell className="number">{group.failures}</TableCell>
+        <TableCell>{dayjs(group.latestFailureTime).fromNow()}</TableCell>
+      </TableRow>
+      {/** Render the remaining rows in the group */}
+      {expanded && children}
+    </>
+  );
+};
+
+export default FailuresTableRows;
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_head/failures_table_head.test.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_head/failures_table_head.test.tsx
new file mode 100644
index 0000000..8b9653b
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_head/failures_table_head.test.tsx
@@ -0,0 +1,44 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import { identityFunction } from '../../../testing_tools/functions';
+import FailuresTableHead from './failures_table_head';
+
+describe('Test FailureTableHead', () => {
+  it('should display sortable table head', async () => {
+    render(
+        <table>
+          <FailuresTableHead
+            isAscending={false}
+            toggleSort={identityFunction}
+            sortMetric={'latestFailureTime'}/>
+        </table>,
+    );
+
+    await (screen.findByTestId('failure_table_head'));
+
+    expect(screen.getByText('User Cls Failed Presubmit')).toBeInTheDocument();
+    expect(screen.getByText('Builds Failed')).toBeInTheDocument();
+    expect(screen.getByText('Presubmit-Blocking Failures Exonerated')).toBeInTheDocument();
+    expect(screen.getByText('Total Failures')).toBeInTheDocument();
+    expect(screen.getByText('Latest Failure Time')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/failures_table/failures_table_head/failures_table_head.tsx b/analysis/frontend/ui/src/components/failures_table/failures_table_head/failures_table_head.tsx
new file mode 100644
index 0000000..9988c0d
--- /dev/null
+++ b/analysis/frontend/ui/src/components/failures_table/failures_table_head/failures_table_head.tsx
@@ -0,0 +1,92 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+import TableSortLabel from '@mui/material/TableSortLabel';
+import { MetricName } from '../../../tools/failures_tools';
+
+interface Props {
+    toggleSort: (metric: MetricName) => void,
+    sortMetric: MetricName,
+    isAscending: boolean,
+}
+
+const FailuresTableHead = ({
+  toggleSort,
+  sortMetric,
+  isAscending,
+}: Props) => {
+  return (
+    <TableHead data-testid="failure_table_head">
+      <TableRow>
+        <TableCell></TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'presubmitRejects' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer' }}>
+          <TableSortLabel
+            aria-label="Sort by User CLs failed Presubmit"
+            active={sortMetric === 'presubmitRejects'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('presubmitRejects')}>
+              User Cls Failed Presubmit
+          </TableSortLabel>
+        </TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'invocationFailures' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer' }}>
+          <TableSortLabel
+            active={sortMetric === 'invocationFailures'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('invocationFailures')}>
+              Builds Failed
+          </TableSortLabel>
+        </TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'criticalFailuresExonerated' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer' }}>
+          <TableSortLabel
+            active={sortMetric === 'criticalFailuresExonerated'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('criticalFailuresExonerated')}>
+              Presubmit-Blocking Failures Exonerated
+          </TableSortLabel>
+        </TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'failures' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer' }}>
+          <TableSortLabel
+            active={sortMetric === 'failures'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('failures')}>
+              Total Failures
+          </TableSortLabel>
+        </TableCell>
+        <TableCell
+          sortDirection={sortMetric === 'latestFailureTime' ? (isAscending ? 'asc' : 'desc') : false}
+          sx={{ cursor: 'pointer' }}>
+          <TableSortLabel
+            active={sortMetric === 'latestFailureTime'}
+            direction={isAscending ? 'asc' : 'desc'}
+            onClick={() => toggleSort('latestFailureTime')}>
+              Latest Failure Time
+          </TableSortLabel>
+        </TableCell>
+      </TableRow>
+    </TableHead>
+  );
+};
+
+export default FailuresTableHead;
diff --git a/analysis/frontend/ui/src/components/grid_label/grid_label.test.tsx b/analysis/frontend/ui/src/components/grid_label/grid_label.test.tsx
new file mode 100644
index 0000000..7ac558c
--- /dev/null
+++ b/analysis/frontend/ui/src/components/grid_label/grid_label.test.tsx
@@ -0,0 +1,45 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import GridLabel from './grid_label';
+
+describe('Test GridLabel component', () => {
+  it('given text only, then should display it', async () => {
+    render(
+        <GridLabel
+          text="Test text"/>,
+    );
+    await screen.findByText('Test text');
+    expect(screen.getByText('Test text')).toBeInTheDocument();
+  });
+
+  it('given text and children, then should display them', async () => {
+    render(
+        <GridLabel
+          text="Test text">
+          <p>I am a child</p>
+        </GridLabel>,
+    );
+    await screen.findByText('Test text');
+    expect(screen.getByText('Test text')).toBeInTheDocument();
+    expect(screen.getByText('I am a child')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/grid_label/grid_label.tsx b/analysis/frontend/ui/src/components/grid_label/grid_label.tsx
new file mode 100644
index 0000000..1219907
--- /dev/null
+++ b/analysis/frontend/ui/src/components/grid_label/grid_label.tsx
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Box from '@mui/material/Box';
+import Grid from '@mui/material/Grid';
+
+interface Props {
+    text?: string;
+    children?: React.ReactNode,
+    xs?: number;
+    lg?: number;
+    testid?: string;
+}
+
+const GridLabel = ({
+  text,
+  children,
+  xs = 2,
+  lg = xs,
+  testid,
+}: Props) => {
+  return (
+    <Grid item xs={xs} lg={lg} data-testid={testid}>
+      <Box
+        sx={{
+          display: 'inline-block',
+          wordBreak: 'break-all',
+          overflowWrap: 'break-word',
+        }}
+        paddingTop={1}>
+        {text}
+      </Box>
+      {children}
+    </Grid>
+  );
+};
+
+export default GridLabel;
diff --git a/analysis/frontend/ui/src/components/help_tooltip/help_tooltip.test.tsx b/analysis/frontend/ui/src/components/help_tooltip/help_tooltip.test.tsx
new file mode 100644
index 0000000..9226574
--- /dev/null
+++ b/analysis/frontend/ui/src/components/help_tooltip/help_tooltip.test.tsx
@@ -0,0 +1,35 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+  fireEvent,
+} from '@testing-library/react';
+
+import HelpTooltip from './help_tooltip';
+
+describe('Test HelpTooltip component', () => {
+  it('given a title, should display it', async () => {
+    render(<HelpTooltip text="I can help you" />);
+
+    await screen.findByRole('button');
+    const button = screen.getByRole('button');
+    fireEvent.mouseOver(button);
+    await screen.findByText('I can help you');
+    expect(screen.getByText('I can help you')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/help_tooltip/help_tooltip.tsx b/analysis/frontend/ui/src/components/help_tooltip/help_tooltip.tsx
new file mode 100644
index 0000000..5a46b2c
--- /dev/null
+++ b/analysis/frontend/ui/src/components/help_tooltip/help_tooltip.tsx
@@ -0,0 +1,33 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Tooltip from '@mui/material/Tooltip';
+import IconButton from '@mui/material/IconButton';
+import HelpOutline from '@mui/icons-material/HelpOutline';
+
+interface Props {
+    text: string;
+}
+
+const HelpTooltip= ({ text }: Props) => {
+  return (
+    <Tooltip arrow title={text}>
+      <IconButton aria-label='What is this?'>
+        <HelpOutline></HelpOutline>
+      </IconButton>
+    </Tooltip>
+  );
+};
+
+export default HelpTooltip;
diff --git a/analysis/frontend/ui/src/components/impact_section/impact_section.tsx b/analysis/frontend/ui/src/components/impact_section/impact_section.tsx
new file mode 100644
index 0000000..6c9d382
--- /dev/null
+++ b/analysis/frontend/ui/src/components/impact_section/impact_section.tsx
@@ -0,0 +1,77 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useQuery } from 'react-query';
+import { useParams } from 'react-router-dom';
+
+import Container from '@mui/material/Container';
+import CircularProgress from '@mui/material/CircularProgress';
+import Grid from '@mui/material/Grid';
+import Paper from '@mui/material/Paper';
+
+import { getClustersService, BatchGetClustersRequest } from '../../services/cluster';
+import ErrorAlert from '../error_alert/error_alert';
+import ImpactTable from '../impact_table/impact_table';
+
+const ImpactSection = () => {
+  const { project, algorithm, id } = useParams();
+  let currentAlgorithm = algorithm;
+  if (!currentAlgorithm) {
+    currentAlgorithm = 'rules';
+  }
+  const clustersService = getClustersService();
+  const { isLoading, isError, isSuccess, data: cluster, error } = useQuery(['cluster', project, currentAlgorithm, id], async () => {
+    const request: BatchGetClustersRequest = {
+      parent: `projects/${encodeURIComponent(project || '')}`,
+      names: [
+        `projects/${encodeURIComponent(project || '')}/clusters/${encodeURIComponent(currentAlgorithm || '')}/${encodeURIComponent(id || '')}`,
+      ],
+    };
+
+    const response = await clustersService.batchGet(request);
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    return response.clusters![0];
+  });
+
+  return (
+    <Paper elevation={3} sx={{ pt: 1, pb: 4 }}>
+      <Container maxWidth={false}>
+        <h2>Impact</h2>
+        {
+          isLoading && (
+            <Grid container item alignItems="center" justifyContent="center">
+              <CircularProgress />
+            </Grid>
+          )
+        }
+        {
+          isError && (
+            <ErrorAlert
+              errorText={`Got an error while loading the cluster: ${error}`}
+              errorTitle="Failed to load cluster"
+              showError/>
+          )
+        }
+        {
+          isSuccess && cluster && (
+            <ImpactTable cluster={cluster}></ImpactTable>
+          )
+        }
+      </Container>
+    </Paper>
+  );
+};
+
+export default ImpactSection;
diff --git a/analysis/frontend/ui/src/components/impact_table/impact_table.test.tsx b/analysis/frontend/ui/src/components/impact_table/impact_table.test.tsx
new file mode 100644
index 0000000..91de200
--- /dev/null
+++ b/analysis/frontend/ui/src/components/impact_table/impact_table.test.tsx
@@ -0,0 +1,37 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import { getMockCluster } from '../../testing_tools/mocks/cluster_mock';
+import ImpactTable from './impact_table';
+
+describe('Test ImpactTable component', () => {
+  it('given a cluster, should display it', async () => {
+    const cluster = getMockCluster('1234567890abcdef1234567890abcdef');
+    render(<ImpactTable cluster={cluster} />);
+
+    await screen.findByText('User Cls Failed Presubmit');
+    // Check for 7d unexpected failures total.
+    expect(screen.getByText('15800')).toBeInTheDocument();
+
+    // Check for 7d critical failures exonerated.
+    expect(screen.getByText('13800')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/impact_table/impact_table.tsx b/analysis/frontend/ui/src/components/impact_table/impact_table.tsx
new file mode 100644
index 0000000..07dc13a
--- /dev/null
+++ b/analysis/frontend/ui/src/components/impact_table/impact_table.tsx
@@ -0,0 +1,76 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Box from '@mui/material/Box';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableContainer from '@mui/material/TableContainer';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+
+import { Cluster, Counts } from '../../services/cluster';
+import HelpTooltip from '../help_tooltip/help_tooltip';
+
+
+const userClsFailedPresubmitTooltipText = 'The number of distinct developer changelists that failed at least one presubmit (CQ) run because of failure(s) in this cluster.';
+const criticalFailuresExoneratedTooltipText = 'The number of failures on test variants which were configured to be presubmit-blocking, which were exonerated (i.e. did not actually block presubmit) because infrastructure determined the test variant to be failing or too flaky at tip-of-tree. If this number is non-zero, it means a test variant which was configured to be presubmit-blocking is not stable enough to do so, and should be fixed or made non-blocking.';
+const totalFailuresTooltipText = 'The total number of test results in this cluster. Weetbix only clusters test results which are unexpected and have a status of crash, abort or fail.';
+
+interface Props {
+    cluster: Cluster;
+}
+
+const ImpactTable = ({ cluster }: Props) => {
+  const metric = (counts: Counts): string => {
+    return counts.nominal || '0';
+  };
+
+  return (
+    <TableContainer component={Box}>
+      <Table data-testid="impact-table" size="small" sx={{ maxWidth: 600 }}>
+        <TableHead>
+          <TableRow>
+            <TableCell></TableCell>
+            <TableCell align="right">1 day</TableCell>
+            <TableCell align="right">3 days</TableCell>
+            <TableCell align="right">7 days</TableCell>
+          </TableRow>
+        </TableHead>
+        <TableBody>
+          <TableRow>
+            <TableCell>User Cls Failed Presubmit <HelpTooltip text={userClsFailedPresubmitTooltipText} /></TableCell>
+            <TableCell align="right">{metric(cluster.userClsFailedPresubmit.oneDay)}</TableCell>
+            <TableCell align="right">{metric(cluster.userClsFailedPresubmit.threeDay)}</TableCell>
+            <TableCell align="right">{metric(cluster.userClsFailedPresubmit.sevenDay)}</TableCell>
+          </TableRow>
+          <TableRow>
+            <TableCell>Presubmit-Blocking Failures Exonerated <HelpTooltip text={criticalFailuresExoneratedTooltipText} /></TableCell>
+            <TableCell align="right">{metric(cluster.criticalFailuresExonerated.oneDay)}</TableCell>
+            <TableCell align="right">{metric(cluster.criticalFailuresExonerated.threeDay)}</TableCell>
+            <TableCell align="right">{metric(cluster.criticalFailuresExonerated.sevenDay)}</TableCell>
+          </TableRow>
+          <TableRow>
+            <TableCell>Total Failures <HelpTooltip text={totalFailuresTooltipText} /></TableCell>
+            <TableCell align="right">{metric(cluster.failures.oneDay)}</TableCell>
+            <TableCell align="right">{metric(cluster.failures.threeDay)}</TableCell>
+            <TableCell align="right">{metric(cluster.failures.sevenDay)}</TableCell>
+          </TableRow>
+        </TableBody>
+      </Table>
+    </TableContainer>
+  );
+};
+
+export default ImpactTable;
diff --git a/analysis/frontend/ui/src/components/recent_failures_section/recent_failures_section.tsx b/analysis/frontend/ui/src/components/recent_failures_section/recent_failures_section.tsx
new file mode 100644
index 0000000..e279dc7
--- /dev/null
+++ b/analysis/frontend/ui/src/components/recent_failures_section/recent_failures_section.tsx
@@ -0,0 +1,46 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useParams } from 'react-router-dom';
+
+import Container from '@mui/material/Container';
+import Paper from '@mui/material/Paper';
+
+import FailuresTable from '../failures_table/failures_table';
+
+const RecentFailuresSection = () => {
+  const { project, algorithm, id } = useParams();
+  let currentAlgorithm = algorithm;
+  if (!currentAlgorithm) {
+    currentAlgorithm = 'rules-v2';
+  }
+
+  return (
+    <Paper elevation={3} sx={{ pt: 1, pb: 4 }}>
+      <Container maxWidth={false}>
+        <h2>Recent Failures</h2>
+        {
+          (id && project) && (
+            <FailuresTable
+              clusterAlgorithm={currentAlgorithm}
+              clusterId={id}
+              project={project}/>
+          )
+        }
+      </Container>
+    </Paper>
+  );
+};
+
+export default RecentFailuresSection;
diff --git a/analysis/frontend/ui/src/components/reclustering_progress_indicator/reclustering_progress_indicator.test.tsx b/analysis/frontend/ui/src/components/reclustering_progress_indicator/reclustering_progress_indicator.test.tsx
new file mode 100644
index 0000000..6356125
--- /dev/null
+++ b/analysis/frontend/ui/src/components/reclustering_progress_indicator/reclustering_progress_indicator.test.tsx
@@ -0,0 +1,109 @@
+/* eslint-disable @typescript-eslint/no-empty-function */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import dayjs from 'dayjs';
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  screen,
+  waitFor,
+} from '@testing-library/react';
+
+import { renderWithClient } from '../../testing_tools/libs/mock_rquery';
+import { mockFetchAuthState } from '../../testing_tools/mocks/authstate_mock';
+import {
+  createMockDoneProgress,
+  createMockProgress,
+} from '../../testing_tools/mocks/progress_mock';
+import ReclusteringProgressIndicator from './reclustering_progress_indicator';
+
+describe('Test ReclusteringProgressIndicator component', () => {
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given an finished progress, then should not display', async () => {
+    mockFetchAuthState();
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Clusters/GetReclusteringProgress', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(createMockDoneProgress()),
+    });
+    renderWithClient(
+        <ReclusteringProgressIndicator
+          project='chromium'
+          hasRule
+          rulePredicateLastUpdated={dayjs().subtract(5, 'minutes').toISOString()}/>,
+    );
+
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+  });
+
+  it('given a progress, then should display percentage', async () => {
+    mockFetchAuthState();
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Clusters/GetReclusteringProgress', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(createMockProgress(800)),
+    });
+    renderWithClient(
+        <ReclusteringProgressIndicator
+          project='chromium'
+          hasRule
+          rulePredicateLastUpdated={dayjs().subtract(5, 'minutes').toISOString()}/>,
+    );
+
+    await screen.findByRole('alert');
+    await screen.findByText('80%');
+
+    expect(screen.getByText('80%')).toBeInTheDocument();
+  });
+
+  it('when progress is done after being on screen, then should display button to refresh analysis', async () => {
+    mockFetchAuthState();
+    fetchMock.postOnce('http://localhost/prpc/weetbix.v1.Clusters/GetReclusteringProgress', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(createMockProgress(800)),
+    });
+    renderWithClient(
+        <ReclusteringProgressIndicator
+          project='chromium'
+          hasRule
+          rulePredicateLastUpdated={dayjs().subtract(5, 'minutes').toISOString()}/>,
+    );
+    await screen.findByRole('alert');
+    await screen.findByText('80%');
+
+    fetchMock.postOnce('http://localhost/prpc/weetbix.v1.Clusters/GetReclusteringProgress', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(createMockDoneProgress()),
+    }, { overwriteRoutes: false });
+
+    await waitFor(() => fetchMock.calls.length == 2);
+
+    await screen.findByRole('button');
+    expect(screen.getByText('View updated impact')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/reclustering_progress_indicator/reclustering_progress_indicator.tsx b/analysis/frontend/ui/src/components/reclustering_progress_indicator/reclustering_progress_indicator.tsx
new file mode 100644
index 0000000..805690a
--- /dev/null
+++ b/analysis/frontend/ui/src/components/reclustering_progress_indicator/reclustering_progress_indicator.tsx
@@ -0,0 +1,179 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import {
+  useEffect,
+  useState,
+} from 'react';
+import {
+  useQuery,
+  useQueryClient,
+} from 'react-query';
+
+import Alert from '@mui/material/Alert';
+import Button from '@mui/material/Button';
+import Grid from '@mui/material/Grid';
+
+import {
+  fetchProgress,
+  noProgressToShow,
+  progressNotYetStarted,
+  progressToLatestAlgorithms,
+  progressToLatestConfig,
+  progressToRulesVersion,
+} from '../../tools/progress_tools';
+
+import CircularProgressWithLabel from '../circular_progress_with_label/circular_progress_with_label';
+import ErrorAlert from '../error_alert/error_alert';
+
+interface Props {
+    project: string;
+    hasRule?: boolean | undefined;
+    rulePredicateLastUpdated?: string | undefined;
+}
+
+const ReclusteringProgressIndicator = ({
+  project,
+  hasRule,
+  rulePredicateLastUpdated,
+}: Props) => {
+  const [show, setShow] = useState(false);
+  const [lastRefreshed, setLastRefreshed] = useState(dayjs());
+
+  const [progressPerMille, setProgressPerMille] = useState(noProgressToShow);
+  const [reclusteringTarget, setReclusteringTarget] = useState('');
+  const queryClient = useQueryClient();
+
+  const { isError, isLoading, data: progress, error } = useQuery(
+      ['reclusteringProgress', project],
+      async () => {
+        return await fetchProgress(project);
+      }, {
+        refetchInterval: () => {
+          // Only update the progress if we are still less than 100%
+          if (progressPerMille >= 1000) {
+            return false;
+          }
+          return 1000;
+        },
+        onSuccess: () => {
+          setLastRefreshed(dayjs());
+        },
+      },
+  );
+
+  useEffect(() => {
+    if (progress) {
+      let currentProgressPerMille = progressToLatestAlgorithms(progress);
+      let currentTarget = 'updated clustering algorithms';
+      const configProgress = progressToLatestConfig(progress);
+      if (configProgress < currentProgressPerMille) {
+        currentTarget = 'updated clustering configuration';
+        currentProgressPerMille = configProgress;
+      }
+      if (hasRule && rulePredicateLastUpdated) {
+        const ruleProgress = progressToRulesVersion(progress, rulePredicateLastUpdated);
+        if (ruleProgress < currentProgressPerMille) {
+          currentTarget = 'the latest rule definition';
+          currentProgressPerMille = ruleProgress;
+        }
+      }
+
+      setReclusteringTarget(currentTarget);
+      setProgressPerMille(currentProgressPerMille);
+    }
+  }, [progress, rulePredicateLastUpdated, hasRule]);
+
+  useEffect(() => {
+    if (progressPerMille >= progressNotYetStarted && progressPerMille < 1000) {
+      setShow(true);
+    }
+  }, [progressPerMille]);
+
+  if (isLoading && !progress) {
+    // no need to show anything if there is no progress and we are still loading
+    return <></>;
+  }
+
+  if (isError || !progress) {
+    return (
+      <ErrorAlert
+        errorText={`Failed to load reclustering progress${error ? ' due to ' + error : '.'}`}
+        errorTitle="Loading reclustering progress failed"
+        showError/>
+    );
+  }
+
+  const handleRefreshAnalysis = () => {
+    queryClient.invalidateQueries('cluster');
+    queryClient.invalidateQueries('clusterFailures');
+    setShow(false);
+  };
+
+  let progressText = 'task queued';
+  if (progressPerMille >= 0) {
+    progressText = (progressPerMille / 10).toFixed(1) + '%';
+  }
+
+  const progressContent = () => {
+    if (progressPerMille < 1000) {
+      return (
+        <>
+          <p>Weetbix is re-clustering test results to reflect {reclusteringTarget} ({progressText}). Cluster impact may be out-of-date.</p>
+          <small> Last update {lastRefreshed.local().toString()}.</small>
+        </>
+      );
+    } else {
+      return 'Weetbix has finished re-clustering test results. Updated cluster impact is now available.';
+    }
+  };
+  return (
+    <>
+      { show &&
+          <Alert
+            severity={progressPerMille >= 1000 ? 'success' : 'info'}
+            icon={false}
+            sx={{
+              mt: 1,
+            }}>
+            <Grid container justifyContent="center" alignItems="center" columnSpacing={{ xs: 2 }}>
+              <Grid item>
+                <CircularProgressWithLabel
+                  variant="determinate"
+                  value={Math.max(0, progressPerMille / 10)}/>
+              </Grid>
+              <Grid item data-testid="reclustering-progress-description">
+                {progressContent()}
+              </Grid>
+              <Grid item>
+                {
+                  progressPerMille >= 1000 && (
+                    <Button
+                      color="inherit"
+                      size="small"
+                      onClick={handleRefreshAnalysis}>
+                                    View updated impact
+                    </Button>
+                  )
+                }
+              </Grid>
+            </Grid>
+          </Alert>
+      }
+    </>
+  );
+};
+
+export default ReclusteringProgressIndicator;
diff --git a/analysis/frontend/ui/src/components/rule/bug_edit_dialog/bug_edit_dialog.test.tsx b/analysis/frontend/ui/src/components/rule/bug_edit_dialog/bug_edit_dialog.test.tsx
new file mode 100644
index 0000000..2d95465
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/bug_edit_dialog/bug_edit_dialog.test.tsx
@@ -0,0 +1,121 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  fireEvent,
+  screen,
+  waitFor,
+} from '@testing-library/react';
+
+import { Rule } from '../../../services/rules';
+import { noopStateChanger } from '../../../testing_tools/functions';
+import { renderWithRouterAndClient } from '../../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../../testing_tools/mocks/authstate_mock';
+import { mockFetchProjectConfig } from '../../../testing_tools/mocks/projects_mock';
+import {
+  createDefaultMockRule,
+  mockFetchRule,
+} from '../../../testing_tools/mocks/rule_mock';
+import BugEditDialog from './bug_edit_dialog';
+
+describe('Test BugEditDialog component', () => {
+  beforeEach(() => {
+    mockFetchProjectConfig();
+    mockFetchAuthState();
+    mockFetchRule();
+  });
+
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given a bug, then should display details', async () => {
+    renderWithRouterAndClient(
+        <BugEditDialog
+          open
+          setOpen={noopStateChanger}/>,
+        '/p/chromium/rules/1234567',
+        '/p/:project/rules/:id',
+    );
+
+    await screen.findByText('Save');
+
+    expect(screen.getByText('Save')).toBeInTheDocument();
+    expect(screen.getByText('Cancel')).toBeInTheDocument();
+    expect(screen.getByText('Bug number')).toBeInTheDocument();
+  });
+
+  it('when cancelled, then should revert changes made', async () => {
+    renderWithRouterAndClient(
+        <BugEditDialog
+          open
+          setOpen={noopStateChanger}/>,
+        '/p/chromium/rules/1234567',
+        '/p/:project/rules/:id',
+    );
+
+    await screen.findByText('Save');
+    fireEvent.change(screen.getByTestId('bug-number'), { target: { value: '6789' } });
+
+    expect(screen.getByTestId('bug-number')).toHaveValue('6789');
+
+    fireEvent.click(screen.getByText('Cancel'));
+
+    await waitFor(() => expect(screen.getByTestId('bug-number')).toHaveValue('920702'));
+  });
+
+  it('when changing bug details, then should update rule', async () => {
+    renderWithRouterAndClient(
+        <BugEditDialog
+          open
+          setOpen={noopStateChanger}/>,
+        '/p/chromium/rules/1234567',
+        '/p/:project/rules/:id',
+    );
+
+    await screen.findByText('Save');
+    fireEvent.change(screen.getByTestId('bug-number'), { target: { value: '6789' } });
+
+    const updatedRule: Rule = {
+      ...createDefaultMockRule(),
+      bug: {
+        id: 'chromium/6789',
+        linkText: 'new-bug',
+        system: 'monorail',
+        url: 'http://linktobug',
+      },
+    };
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Rules/Update', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\'' + JSON.stringify(updatedRule),
+    });
+    fireEvent.click(screen.getByText('Save'));
+    await waitFor(() => fetchMock.lastCall() !== undefined && fetchMock.lastCall()![0] === 'http://localhost/prpc/weetbix.v1.Rules/Update');
+    expect(fetchMock.lastCall()![1]!.body).toEqual('{"rule":'+
+        '{"name":"projects/chromium/rules/ce83f8395178a0f2edad59fc1a167818",'+
+        '"bug":{"system":"monorail","id":"chromium/6789"'+
+        '}},'+
+        '"updateMask":"bug","etag":"W/\\"2022-01-31T03:36:14.89643Z\\""}');
+    expect(screen.getByTestId('bug-number')).toHaveValue('6789');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/rule/bug_edit_dialog/bug_edit_dialog.tsx b/analysis/frontend/ui/src/components/rule/bug_edit_dialog/bug_edit_dialog.tsx
new file mode 100644
index 0000000..1e07164
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/bug_edit_dialog/bug_edit_dialog.tsx
@@ -0,0 +1,142 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  Dispatch,
+  SetStateAction,
+  useEffect,
+  useState,
+} from 'react';
+import { useParams } from 'react-router-dom';
+
+import LoadingButton from '@mui/lab/LoadingButton';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import LinearProgress from '@mui/material/LinearProgress';
+
+import useFetchRule from '../../../hooks/useFetchRule';
+import { useMutateRule } from '../../../hooks/useMutateRule';
+import { UpdateRuleRequest } from '../../../services/rules';
+import BugPicker from '../../bug_picker/bug_picker';
+import ErrorAlert from '../../error_alert/error_alert';
+
+interface Props {
+    open: boolean;
+    setOpen: Dispatch<SetStateAction<boolean>>;
+}
+
+const BugEditDialog = ({
+  open,
+  setOpen,
+}: Props) => {
+  const { project, id: ruleId } = useParams();
+
+  const { isLoading, isError, data: rule, error } = useFetchRule(ruleId, project);
+
+  const [bugSystem, setBugSystem] = useState('');
+  const [bugId, setBugId] = useState('');
+
+  const mutateRule = useMutateRule(() => {
+    setOpen(false);
+  });
+
+  useEffect(() => {
+    if (rule) {
+      setBugId(rule.bug.id);
+      setBugSystem(rule.bug.system);
+    }
+  }, [rule]);
+
+  if (!ruleId || !project) {
+    return <ErrorAlert
+      errorText={'Project and/or rule are not defined in the URL'}
+      errorTitle="Project and/or rule are undefined"
+      showError/>;
+  }
+
+  if (isError || !rule) {
+    return <ErrorAlert
+      errorText={`An erro occured while fetching the rule: ${error}`}
+      errorTitle="Failed to load rule"
+      showError/>;
+  }
+
+  const handleBugSystemChanged = (bugSystem: string) => {
+    setBugSystem(bugSystem);
+  };
+
+  const handleBugIdChanged = (bugId: string) => {
+    setBugId(bugId);
+  };
+
+  const handleClose = () => {
+    setBugSystem(rule.bug.system);
+    setBugId(rule.bug.id);
+    setOpen(false);
+  };
+
+  const handleSave = () => {
+    const request: UpdateRuleRequest = {
+      rule: {
+        name: rule.name,
+        bug: {
+          system: bugSystem,
+          id: bugId,
+        },
+      },
+      updateMask: 'bug',
+      etag: rule.etag,
+    };
+    mutateRule.mutate(request);
+  };
+
+  if (isLoading) {
+    return <LinearProgress />;
+  }
+
+  return (
+    <>
+      <Dialog open={open} fullWidth>
+        <DialogTitle>Change associated bug</DialogTitle>
+        <DialogContent sx={{ mt: 1 }}>
+          <BugPicker
+            bugSystem={bugSystem}
+            bugId={bugId}
+            handleBugSystemChanged={handleBugSystemChanged}
+            handleBugIdChanged={handleBugIdChanged}/>
+        </DialogContent>
+        <DialogActions>
+          <Button
+            variant="outlined"
+            data-testid="bug-edit-dialog-cancel"
+            onClick={handleClose}>
+              Cancel
+          </Button>
+          <LoadingButton
+            variant="contained"
+            data-testid="bug-edit-dialog-save"
+            onClick={handleSave}
+            loading={mutateRule.isLoading}>
+              Save
+          </LoadingButton>
+        </DialogActions>
+      </Dialog>
+    </>
+  );
+};
+
+export default BugEditDialog;
diff --git a/analysis/frontend/ui/src/components/rule/bug_info/bug_info.test.tsx b/analysis/frontend/ui/src/components/rule/bug_info/bug_info.test.tsx
new file mode 100644
index 0000000..5b21805
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/bug_info/bug_info.test.tsx
@@ -0,0 +1,110 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  fireEvent,
+  screen,
+} from '@testing-library/react';
+
+import { Issue } from '../../../services/monorail';
+import { Rule } from '../../../services/rules';
+import { renderWithRouterAndClient } from '../../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../../testing_tools/mocks/authstate_mock';
+import { createMockBug } from '../../../testing_tools/mocks/bug_mock';
+import { mockFetchProjectConfig } from '../../../testing_tools/mocks/projects_mock';
+import { createDefaultMockRule, mockFetchRule } from '../../../testing_tools/mocks/rule_mock';
+import BugInfo from './bug_info';
+
+describe('Test BugInfo component', () => {
+  let mockRule!: Rule;
+  let mockIssue!: Issue;
+
+  beforeEach(() => {
+    mockFetchAuthState();
+    mockRule = createDefaultMockRule();
+    mockIssue = createMockBug();
+    mockFetchRule();
+  });
+
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given a rule with monorail bug, should fetch and display bug info', async () => {
+    fetchMock.post('https://api-dot-crbug.com/prpc/monorail.v3.Issues/GetIssue', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\'' + JSON.stringify(mockIssue),
+    });
+
+    renderWithRouterAndClient(
+        <BugInfo
+          rule={mockRule}/>,
+    );
+
+    expect(screen.getByText(mockRule.bug.linkText)).toBeInTheDocument();
+
+    await screen.findByText('Status');
+    expect(screen.getByText(mockIssue.summary)).toBeInTheDocument();
+    expect(screen.getByText(mockIssue.status.status)).toBeInTheDocument();
+  });
+
+  it('given a rule with buganizer bug, should display bug only', async () => {
+    mockRule.bug = {
+      system: 'buganizer',
+      id: '541231',
+      linkText: 'b/541231',
+      url: 'https://issuetracker.google.com/issues/541231',
+    };
+
+    renderWithRouterAndClient(
+        <BugInfo
+          rule={mockRule}/>,
+    );
+
+    expect(screen.getByText(mockRule.bug.linkText)).toBeInTheDocument();
+  });
+
+  it('when clicking edit, should open dialog, even if bug does not load', async () => {
+    // Check we can still edit the bug, even if the bug fails to load.
+    fetchMock.post('https://api-dot-crbug.com/prpc/monorail.v3.Issues/GetIssue', {
+      status: 404,
+      headers: {
+        'X-Prpc-Grpc-Code': '5',
+      },
+      body: 'Issue(s) not found',
+    });
+
+    mockFetchProjectConfig();
+    renderWithRouterAndClient(
+        <BugInfo
+          rule={mockRule}/>,
+        '/p/chromium/rules/123456',
+        '/p/:project/rules/:id',
+    );
+
+    await screen.findByText('Associated Bug');
+
+    fireEvent.click(screen.getByLabelText('edit'));
+
+    expect(screen.getByText('Change associated bug')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/rule/bug_info/bug_info.tsx b/analysis/frontend/ui/src/components/rule/bug_info/bug_info.tsx
new file mode 100644
index 0000000..3e2bc35
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/bug_info/bug_info.tsx
@@ -0,0 +1,191 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useState } from 'react';
+import { useQuery } from 'react-query';
+
+import Edit from '@mui/icons-material/Edit';
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import CircularProgress from '@mui/material/CircularProgress';
+import Container from '@mui/material/Container';
+import Divider from '@mui/material/Divider';
+import Grid from '@mui/material/Grid';
+import IconButton from '@mui/material/IconButton';
+import LinearProgress from '@mui/material/LinearProgress';
+import Link from '@mui/material/Link';
+import Paper from '@mui/material/Paper';
+import Switch from '@mui/material/Switch';
+import Typography from '@mui/material/Typography';
+
+import { useMutateRule } from '../../../hooks/useMutateRule';
+import { AssociatedBug } from '../../../services/shared_models';
+import {
+  GetIssueRequest,
+  getIssuesService,
+} from '../../../services/monorail';
+import {
+  Rule,
+  UpdateRuleRequest,
+} from '../../../services/rules';
+import { MuiDefaultColor } from '../../../types/mui_types';
+import ErrorAlert from '../../error_alert/error_alert';
+import GridLabel from '../../grid_label/grid_label';
+import HelpTooltip from '../../help_tooltip/help_tooltip';
+import BugEditDialog from '../bug_edit_dialog/bug_edit_dialog';
+
+const createIssueServiceRequest = (bug: AssociatedBug): GetIssueRequest => {
+  const parts = bug.id.split('/');
+  const monorailProject = parts[0];
+  const bugId = parts[1];
+  const issueId = `projects/${monorailProject}/issues/${bugId}`;
+  return {
+    name: issueId,
+  };
+};
+
+const bugStatusColor = (status: string): MuiDefaultColor => {
+  // In monorail, bug statuses are configurable per system. Right now,
+  // we don't have a configurable mapping from status to semantic in
+  // Weetbix. We will try to recognise common terminology and fall
+  // back to "other" status otherwise.
+  status = status.toLowerCase();
+  const unassigned = ['new', 'untriaged', 'available'];
+  const assigned = ['accepted', 'assigned', 'started', 'externaldependency'];
+  const fixed = ['fixed', 'verified'];
+  if (unassigned.indexOf(status) >= 0) {
+    return 'error';
+  } else if (assigned.indexOf(status) >= 0) {
+    return 'primary';
+  } else if (fixed.indexOf(status) >= 0) {
+    return 'success';
+  } else {
+    // E.g. Won't fix, duplicate, archived.
+    return 'info';
+  }
+};
+
+const bugUpdatesHelpText = 'Whether the priority and verified status of the associated bug should be' +
+    ' automatically updated based on cluster impact. Only one rule may be set to' +
+    ' update a given bug at any one time.';
+
+interface Props {
+    rule: Rule;
+}
+
+const BugInfo = ({
+  rule,
+}: Props) => {
+  const issueService = getIssuesService();
+
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+
+  const isMonorail = (rule.bug.system == 'monorail');
+  const requestName = rule.bug.system + '/' + rule.bug.id;
+  const { isLoading, isError, data: issue, error } = useQuery(['bug', requestName],
+      async () => {
+        if (isMonorail) {
+          const fetchBugRequest = createIssueServiceRequest(rule.bug);
+          return await issueService.getIssue(fetchBugRequest);
+        }
+        return null;
+      },
+  );
+
+  const mutateRule = useMutateRule();
+
+  const handleToggleUpdateBug = () => {
+    const request: UpdateRuleRequest = {
+      rule: {
+        name: rule.name,
+        isManagingBug: !rule.isManagingBug,
+      },
+      updateMask: 'isManagingBug',
+      etag: rule.etag,
+    };
+    mutateRule.mutate(request);
+  };
+
+  return (
+    <Paper data-cy="bug-info" elevation={3} sx={{ pt: 2, pb: 2, mt: 1 }}>
+      <Container maxWidth={false}>
+        <Typography sx={{
+          fontWeight: 600,
+          fontSize: 20,
+        }}>
+            Associated Bug
+        </Typography>
+        <Grid container rowGap={0}>
+          <GridLabel xs={4} lg={2} text="Bug">
+          </GridLabel>
+          <Grid container item xs={8} lg={5} alignItems="center" columnGap={1}>
+            <Link data-testid="bug" target="_blank" href={rule.bug.url}>
+              {rule.bug.linkText}
+            </Link>
+            <IconButton data-testid="bug-edit" aria-label="edit" onClick={() => setEditDialogOpen(true)}>
+              <Edit />
+            </IconButton>
+          </Grid>
+          <GridLabel xs={4} lg={3} text="Update bug">
+            <HelpTooltip text={bugUpdatesHelpText} />
+          </GridLabel>
+          <Grid container item xs={8} lg={2} alignItems="center">
+            {mutateRule.isLoading && (<CircularProgress size="1rem" />)}
+            <Switch
+              data-testid="update-bug-toggle"
+              aria-label="receive bug status"
+              checked={rule.isManagingBug}
+              onChange={handleToggleUpdateBug}
+              disabled={mutateRule.isLoading}/>
+          </Grid>
+        </Grid>
+        <Box sx={{ py: 2 }}>
+          <Divider />
+        </Box>
+        {
+          isLoading && (
+            <LinearProgress />
+          )
+        }
+        {
+          isError && (
+            <Container>
+              <ErrorAlert
+                showError={true}
+                errorTitle='Failed to load bug details.'
+                errorText={`Failed to load bug details due to: ${error}`}/>
+            </Container>
+          )
+        }
+        {
+          issue && (
+            <Grid container rowGap={1}>
+              <GridLabel xs={4} lg={2} text="Status" />
+              <Grid container item xs={8} lg={10} data-testid="bug-status">
+                <Chip label={issue.status.status} color={bugStatusColor(issue.status.status)} />
+              </Grid>
+              <GridLabel xs={4} lg={2} text="Summary" />
+              <GridLabel xs={8} lg={10} testid="bug-summary" text={issue.summary} />
+            </Grid>
+          )
+        }
+      </Container>
+      <BugEditDialog
+        open={editDialogOpen}
+        setOpen={setEditDialogOpen}/>
+    </Paper>
+  );
+};
+
+export default BugInfo;
diff --git a/analysis/frontend/ui/src/components/rule/rule_edit_dialog/rule_edit_dialog.test.tsx b/analysis/frontend/ui/src/components/rule/rule_edit_dialog/rule_edit_dialog.test.tsx
new file mode 100644
index 0000000..8fd6bcd
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/rule_edit_dialog/rule_edit_dialog.test.tsx
@@ -0,0 +1,106 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  fireEvent,
+  screen,
+  waitFor,
+} from '@testing-library/react';
+
+import { Rule } from '../../../services/rules';
+import { identityFunction } from '../../../testing_tools/functions';
+import { renderWithClient } from '../../../testing_tools/libs/mock_rquery';
+import { mockFetchAuthState } from '../../../testing_tools/mocks/authstate_mock';
+import { createDefaultMockRule } from '../../../testing_tools/mocks/rule_mock';
+import RuleEditDialog from './rule_edit_dialog';
+
+describe('Test RuleEditDialog component', () => {
+  afterEach(() => {
+    fetchMock.mockClear();
+    fetchMock.reset();
+  });
+
+  it('given a rule, should display the rule\'s current text', async () => {
+    const mockRule = createDefaultMockRule();
+
+    renderWithClient(
+        <RuleEditDialog
+          open
+          rule={mockRule}
+          setOpen={identityFunction}/>,
+    );
+    await screen.findByTestId('rule-input');
+
+    expect(screen.getByText(mockRule.ruleDefinition)).toBeInTheDocument();
+  });
+
+  it('when modifying the rule\'s text, then should update the rule', async () => {
+    const mockRule = createDefaultMockRule();
+    mockFetchAuthState();
+
+    renderWithClient(
+        <RuleEditDialog
+          open
+          rule={mockRule}
+          setOpen={identityFunction}/>,
+    );
+
+    await screen.findByTestId('rule-input');
+
+    fireEvent.change(screen.getByTestId('rule-input'), { target: { value: 'new rule definition' } });
+
+    const updatedRule: Rule = {
+      ...mockRule,
+      ruleDefinition: 'new rule definition',
+    };
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Rules/Update', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(updatedRule),
+    });
+
+    fireEvent.click(screen.getByText('Save'));
+    await waitFor(() => fetchMock.lastCall() !== undefined && fetchMock.lastCall()![0] === 'http://localhost/prpc/weetbix.v1.Rules/Update');
+
+    expect(fetchMock.lastCall()![1]!.body).toEqual('{"rule":{"name":"projects/chromium/rules/ce83f8395178a0f2edad59fc1a167818",' +
+        '"ruleDefinition":"new rule definition"},' +
+        '"updateMask":"ruleDefinition","etag":"W/\\"2022-01-31T03:36:14.89643Z\\""' +
+        '}');
+  });
+
+  it('when canceling the changes, then should revert', async () => {
+    const mockRule = createDefaultMockRule();
+
+    renderWithClient(
+        <RuleEditDialog
+          open
+          rule={mockRule}
+          setOpen={identityFunction}/>,
+    );
+    await screen.findByTestId('rule-input');
+
+    fireEvent.change(screen.getByTestId('rule-input'), { target: { value: 'new rule definition' } });
+
+    fireEvent.click(screen.getByText('Cancel'));
+
+    expect(screen.getByTestId('rule-input')).toHaveValue('test = "blink_lint_expectations"');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/rule/rule_edit_dialog/rule_edit_dialog.tsx b/analysis/frontend/ui/src/components/rule/rule_edit_dialog/rule_edit_dialog.tsx
new file mode 100644
index 0000000..510ce06
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/rule_edit_dialog/rule_edit_dialog.tsx
@@ -0,0 +1,116 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  ChangeEvent,
+  Dispatch,
+  SetStateAction,
+  useState,
+} from 'react';
+
+import LoadingButton from '@mui/lab/LoadingButton';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import TextField from '@mui/material/TextField';
+
+import { useMutateRule } from '../../../hooks/useMutateRule';
+import {
+  Rule,
+  UpdateRuleRequest,
+} from '../../../services/rules';
+
+interface Props {
+    open: boolean;
+    setOpen: Dispatch<SetStateAction<boolean>>;
+    rule: Rule;
+}
+
+
+const RuleEditDialog = ({
+  open = false,
+  setOpen,
+  rule,
+}: Props) => {
+  const [currentRuleDefinition, setCurrentRuleDefinition] = useState(rule.ruleDefinition);
+
+  const mutateRule = useMutateRule(() => {
+    setOpen(false);
+  });
+  const handleDefinitionChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
+    setCurrentRuleDefinition(e.target.value);
+  };
+
+  const handleClose = () => {
+    setCurrentRuleDefinition(() => rule.ruleDefinition);
+    setOpen(() => false);
+  };
+
+  const handleSave = () => {
+    const request: UpdateRuleRequest = {
+      rule: {
+        name: rule.name,
+        ruleDefinition: currentRuleDefinition,
+      },
+      updateMask: 'ruleDefinition',
+      etag: rule.etag,
+    };
+    mutateRule.mutate(request);
+  };
+
+  return (
+    <Dialog
+      open={open}
+      maxWidth="lg"
+      fullWidth>
+      <DialogTitle>Edit rule definition</DialogTitle>
+      <DialogContent>
+        <TextField
+          id="rule-definition-input"
+          label="Definition"
+          multiline
+          margin="dense"
+          rows={4}
+          value={currentRuleDefinition}
+          onChange={handleDefinitionChange}
+          fullWidth
+          variant="filled"
+          inputProps={{ 'data-testid': 'rule-input' }}/>
+        <small>
+            Supported is AND, OR, =,{'<>'}, NOT, IN, LIKE, parentheses and <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators#regexp_contains">REGEXP_CONTAINS</a>.
+            Valid identifiers are <em>test</em> and <em>reason</em>.
+        </small>
+      </DialogContent>
+      <DialogActions>
+        <Button
+          variant="outlined"
+          data-testid="rule-edit-dialog-cancel"
+          onClick={handleClose}>
+            Cancel
+        </Button>
+        <LoadingButton
+          variant="contained"
+          data-testid="rule-edit-dialog-save"
+          onClick={handleSave}
+          loading={mutateRule.isLoading}>
+            Save
+        </LoadingButton>
+      </DialogActions>
+    </Dialog>
+  );
+};
+
+export default RuleEditDialog;
diff --git a/analysis/frontend/ui/src/components/rule/rule_info/rule_info.test.tsx b/analysis/frontend/ui/src/components/rule/rule_info/rule_info.test.tsx
new file mode 100644
index 0000000..d7e864e
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/rule_info/rule_info.test.tsx
@@ -0,0 +1,101 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  fireEvent,
+  screen,
+  waitFor,
+} from '@testing-library/react';
+
+import { Rule } from '../../../services/rules';
+import { renderWithRouterAndClient } from '../../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../../testing_tools/mocks/authstate_mock';
+import { createDefaultMockRule } from '../../../testing_tools/mocks/rule_mock';
+import RuleInfo from './rule_info';
+
+describe('Test RuleInfo component', () => {
+  it('given a rule, then should display rule details', async () => {
+    const mockRule = createDefaultMockRule();
+    renderWithRouterAndClient(
+        <RuleInfo
+          project="chromium"
+          rule={mockRule}/>,
+    );
+
+    await screen.findByText('Rule Details');
+
+    expect(screen.getByText(mockRule.ruleDefinition)).toBeInTheDocument();
+    expect(screen.getByText(`${mockRule.sourceCluster.algorithm}/${mockRule.sourceCluster.id}`)).toBeInTheDocument();
+    expect(screen.getByText('Archived')).toBeInTheDocument();
+    expect(screen.getByText('No')).toBeInTheDocument();
+  });
+
+  it('when clicking on archived, then should show confirmation dialog', async () => {
+    const mockRule = createDefaultMockRule();
+
+    renderWithRouterAndClient(
+        <RuleInfo
+          project="chromium"
+          rule={mockRule}/>,
+    );
+    await screen.findByText('Rule Details');
+
+    fireEvent.click(screen.getByText('Archive'));
+    await screen.findByText('Are you sure?');
+
+    expect(screen.getByText('Confirm')).toBeInTheDocument();
+  });
+
+  it('when confirming the archival, then should send archival request', async () => {
+    mockFetchAuthState();
+    const mockRule = createDefaultMockRule();
+    renderWithRouterAndClient(
+        <RuleInfo
+          project="chromium"
+          rule={mockRule}/>,
+    );
+    await screen.findByText('Rule Details');
+
+    fireEvent.click(screen.getByText('Archive'));
+    await screen.findByText('Are you sure?');
+
+    expect(screen.getByText('Confirm')).toBeInTheDocument();
+
+    const updatedRule: Rule = {
+      ...mockRule,
+      isActive: false,
+    };
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Rules/Update', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(updatedRule),
+    });
+
+    fireEvent.click(screen.getByText('Confirm'));
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    await waitFor(() => fetchMock.lastCall() !== undefined && fetchMock.lastCall()![0] === 'http://localhost/prpc/weetbix.v1.Rules/Update');
+
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    expect(fetchMock.lastCall()![1]!.body).toEqual('{"rule":{"name":"projects/chromium/rules/ce83f8395178a0f2edad59fc1a167818",' +
+        '"isActive":false},' +
+        '"updateMask":"isActive","etag":"W/\\"2022-01-31T03:36:14.89643Z\\""' +
+        '}');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/rule/rule_info/rule_info.tsx b/analysis/frontend/ui/src/components/rule/rule_info/rule_info.tsx
new file mode 100644
index 0000000..4b8af11
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/rule_info/rule_info.tsx
@@ -0,0 +1,154 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useState } from 'react';
+import { Link as RouterLink } from 'react-router-dom';
+
+import Archive from '@mui/icons-material/Archive';
+import Edit from '@mui/icons-material/Edit';
+import Unarchive from '@mui/icons-material/Unarchive';
+import LoadingButton from '@mui/lab/LoadingButton';
+import Container from '@mui/material/Container';
+import Grid from '@mui/material/Grid';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import Link from '@mui/material/Link';
+import Paper from '@mui/material/Paper';
+import Typography from '@mui/material/Typography';
+
+import { useMutateRule } from '../../../hooks/useMutateRule';
+import {
+  Rule,
+  UpdateRuleRequest,
+} from '../../../services/rules';
+import { linkToCluster } from '../../../tools/urlHandling/links';
+import CodeBlock from '../../codeblock/codeblock';
+import ConfirmDialog from '../../confirm_dialog/confirm_dialog';
+import GridLabel from '../../grid_label/grid_label';
+import HelpTooltip from '../../help_tooltip/help_tooltip';
+import RuleEditDialog from '../rule_edit_dialog/rule_edit_dialog';
+
+const definitionTooltipText = 'The failures matched by this rule.';
+const archivedTooltipText = 'Archived failure association rules do not match failures. If a rule is no longer needed, it should be archived.';
+const sourceClusterTooltipText = 'The cluster this rule was originally created from.';
+interface Props {
+    project: string;
+    rule: Rule;
+}
+
+const RuleInfo = ({ project, rule }: Props) => {
+  const [editDialogOpen, setEditDialogOpen] = useState(false);
+  const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
+
+  const mutateRule = useMutateRule();
+
+  const toggleArchived = () => {
+    const request: UpdateRuleRequest = {
+      rule: {
+        name: rule.name,
+        isActive: !rule.isActive,
+      },
+      updateMask: 'isActive',
+      etag: rule.etag,
+    };
+    mutateRule.mutate(request);
+  };
+
+  const onArchiveConfirm = () => {
+    setConfirmDialogOpen(false);
+    toggleArchived();
+  };
+
+  const onArchiveCancel = () => {
+    setConfirmDialogOpen(false);
+  };
+
+  return (
+    <Paper data-cy="rule-info" elevation={3} sx={{ pt: 2, pb: 2, mt: 1 }} >
+      <Container maxWidth={false}>
+        <Typography sx={{
+          fontWeight: 600,
+          fontSize: 20,
+        }}>
+            Rule Details
+        </Typography>
+        <Grid container rowGap={1}>
+          <GridLabel text="Rule definition">
+            <HelpTooltip text={definitionTooltipText} />
+          </GridLabel>
+          <Grid item xs={10} alignItems="center">
+            <IconButton data-testid="rule-definition-edit" onClick={() => setEditDialogOpen(true)} aria-label="edit" sx={{ float: 'right' }}>
+              <Edit />
+            </IconButton>
+            <Box data-testid="rule-definition" sx={{ display: 'grid' }}>
+              <CodeBlock code={rule.ruleDefinition} />
+            </Box>
+          </Grid>
+          <GridLabel text="Source cluster">
+            <HelpTooltip text={sourceClusterTooltipText} />
+          </GridLabel>
+          <Grid item xs={10} alignItems="center">
+            <Box sx={{ display: 'inline-block' }} paddingTop={1}>
+              {
+                rule.sourceCluster.algorithm && rule.sourceCluster.id ? (
+                  <Link aria-label='source cluster link' component={RouterLink} to={linkToCluster(project, rule.sourceCluster)}>
+                    {rule.sourceCluster.algorithm}/{rule.sourceCluster.id}
+                  </Link>
+                ) : (
+                    'None'
+                )
+              }
+            </Box>
+          </Grid>
+          <GridLabel text="Archived">
+            <HelpTooltip text={archivedTooltipText} />
+          </GridLabel>
+          <Grid item xs={10} alignItems="center" columnGap={1}>
+            <Box data-testid="rule-archived" sx={{ display: 'inline-block' }} paddingTop={1} paddingRight={1}>
+              {rule.isActive ? 'No' : 'Yes'}
+            </Box>
+            <LoadingButton
+              data-testid="rule-archived-toggle"
+              loading={mutateRule.isLoading}
+              variant="outlined"
+              startIcon={rule.isActive ? (<Archive />) : (<Unarchive />)}
+              onClick={() => setConfirmDialogOpen(true)}>
+              {rule.isActive ? 'Archive' : 'Restore'}
+            </LoadingButton>
+          </Grid>
+        </Grid>
+      </Container>
+      <ConfirmDialog
+        open={confirmDialogOpen}
+        message={
+          rule.isActive?
+          'Impact and recent failures are not available for archived rules.'+
+          ' Automatic bug priority updates and auto-closure will also cease.'+
+          ' You can restore archived rules at any time.' :
+          'Weetbix automatically archives rules when the associated bug has'+
+          ' been closed for 30 days. Please make sure the associated bug is'+
+          ' no longer closed to avoid this rule being automatically'+
+          ' re-archived.'
+        }
+        onConfirm={onArchiveConfirm}
+        onCancel={onArchiveCancel}/>
+      <RuleEditDialog
+        open={editDialogOpen}
+        setOpen={setEditDialogOpen}
+        rule={rule}/>
+    </Paper>
+  );
+};
+
+export default RuleInfo;
diff --git a/analysis/frontend/ui/src/components/rule/rule_top_panel/rule_top_panel.test.tsx b/analysis/frontend/ui/src/components/rule/rule_top_panel/rule_top_panel.test.tsx
new file mode 100644
index 0000000..735d6ac
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/rule_top_panel/rule_top_panel.test.tsx
@@ -0,0 +1,66 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import fetchMock from 'fetch-mock-jest';
+
+import { screen } from '@testing-library/react';
+
+import { renderWithRouterAndClient } from '../../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../../testing_tools/mocks/authstate_mock';
+import { createMockBug } from '../../../testing_tools/mocks/bug_mock';
+import { createMockDoneProgress } from '../../../testing_tools/mocks/progress_mock';
+import { mockFetchProjectConfig } from '../../../testing_tools/mocks/projects_mock';
+import { createDefaultMockRule } from '../../../testing_tools/mocks/rule_mock';
+import RuleTopPanel from './rule_top_panel';
+
+describe('Test RuleTopPanel component', () => {
+  it('given a rule, should display rule and bug details', async () => {
+    mockFetchProjectConfig();
+    mockFetchAuthState();
+    const mockRule = createDefaultMockRule();
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Clusters/GetReclusteringProgress', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(createMockDoneProgress()),
+    });
+    fetchMock.post('https://api-dot-crbug.com/prpc/monorail.v3.Issues/GetIssue', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\'' + JSON.stringify(createMockBug()),
+    });
+    fetchMock.post('http://localhost/prpc/weetbix.v1.Rules/Get', {
+      headers: {
+        'X-Prpc-Grpc-Code': '0',
+      },
+      body: ')]}\''+JSON.stringify(mockRule),
+    });
+
+    renderWithRouterAndClient(
+        <RuleTopPanel
+          project="chromium"
+          ruleId='12345'/>,
+        '/p/chromium/rules/12345',
+        '/p/:project/rules/:id',
+    );
+    await screen.findByText('Rule Details');
+
+    expect(screen.getByText('Rule Details')).toBeInTheDocument();
+    expect(screen.getByText('Associated Bug')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/rule/rule_top_panel/rule_top_panel.tsx b/analysis/frontend/ui/src/components/rule/rule_top_panel/rule_top_panel.tsx
new file mode 100644
index 0000000..56eaad2
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rule/rule_top_panel/rule_top_panel.tsx
@@ -0,0 +1,77 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import Grid from '@mui/material/Grid';
+import LinearProgress from '@mui/material/LinearProgress';
+
+import useFetchRule from '../../../hooks/useFetchRule';
+import ErrorAlert from '../../error_alert/error_alert';
+import ReclusteringProgressIndicator from '../../reclustering_progress_indicator/reclustering_progress_indicator';
+import TimestampInfoBar from '../../timestamp_info_bar/timestamp_info_bar';
+import BugInfo from '../bug_info/bug_info';
+import RuleInfo from '../rule_info/rule_info';
+
+interface Props {
+    project: string;
+    ruleId: string;
+}
+
+const RuleTopPanel = ({ project, ruleId }: Props) => {
+  const { isLoading, isError, data: rule, error } = useFetchRule(ruleId, project);
+
+  if (isLoading) {
+    return <LinearProgress />;
+  }
+
+  if (isError) {
+    return (
+      <ErrorAlert
+        errorText={`An error occured while fetching the rule: ${error}`}
+        errorTitle="Failed to load rule"
+        showError/>
+    );
+  }
+
+  return (
+    <>
+      {rule &&
+          <Grid container columnSpacing={2}>
+            <Grid item xs={12}>
+              <ReclusteringProgressIndicator
+                hasRule={true}
+                project={project}
+                rulePredicateLastUpdated={rule.predicateLastUpdateTime}/>
+            </Grid>
+            <Grid item xs={12}>
+              <TimestampInfoBar
+                createUsername={rule.createUser}
+                createTime={rule.createTime}
+                updateUsername={rule.lastUpdateUser}
+                updateTime={rule.lastUpdateTime}/>
+            </Grid>
+            <Grid container item xs={12} columnSpacing={2}>
+              <Grid item xs={12} lg={8} display="grid">
+                <RuleInfo project={project} rule={rule} />
+              </Grid>
+              <Grid item xs={12} lg={4} display="grid">
+                <BugInfo rule={rule} />
+              </Grid>
+            </Grid>
+          </Grid>
+      }
+    </>
+  );
+};
+
+export default RuleTopPanel;
diff --git a/analysis/frontend/ui/src/components/rules_table/rules_table.test.tsx b/analysis/frontend/ui/src/components/rules_table/rules_table.test.tsx
new file mode 100644
index 0000000..4d09475
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rules_table/rules_table.test.tsx
@@ -0,0 +1,43 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+import 'node-fetch';
+
+import { screen } from '@testing-library/react';
+
+import { renderWithRouterAndClient } from '../../testing_tools/libs/mock_router';
+import { mockFetchAuthState } from '../../testing_tools/mocks/authstate_mock';
+import { mockFetchRules } from '../../testing_tools/mocks/rules_mock';
+import RulesTable from './rules_table';
+
+describe('Test RulesTable component', () => {
+  it('given a project, should display the active rules', async () => {
+    mockFetchAuthState();
+    mockFetchRules();
+
+    renderWithRouterAndClient(
+        <RulesTable
+          project='chromium'/>,
+        '/p/chromium/rules',
+        '/p/:project/rules',
+    );
+    await screen.findByText('Rule Definition');
+
+    expect(screen.getByText('crbug.com/90001')).toBeInTheDocument();
+    expect(screen.getByText('crbug.com/90002')).toBeInTheDocument();
+    expect(screen.getByText('test LIKE "rule1%"')).toBeInTheDocument();
+    expect(screen.getByText('reason LIKE "rule2%"')).toBeInTheDocument();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/rules_table/rules_table.tsx b/analysis/frontend/ui/src/components/rules_table/rules_table.tsx
new file mode 100644
index 0000000..dde0308
--- /dev/null
+++ b/analysis/frontend/ui/src/components/rules_table/rules_table.tsx
@@ -0,0 +1,95 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+
+import { useQuery } from 'react-query';
+import { Link as RouterLink } from 'react-router-dom';
+
+import Box from '@mui/material/Box';
+import Table from '@mui/material/Table';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableContainer from '@mui/material/TableContainer';
+import TableHead from '@mui/material/TableHead';
+import TableRow from '@mui/material/TableRow';
+import LinearProgress from '@mui/material/LinearProgress';
+import Link from '@mui/material/Link';
+
+import { getRulesService, ListRulesRequest } from '../../services/rules';
+import { linkToRule } from '../../tools/urlHandling/links';
+import ErrorAlert from '../error_alert/error_alert';
+
+interface Props {
+  project: string;
+}
+
+const RulesTable = ({ project } : Props ) => {
+  const rulesService = getRulesService();
+  const { isLoading, isError, data: rules, error } = useQuery(['rules', project], async () => {
+    const request: ListRulesRequest = {
+      parent: `projects/${encodeURIComponent(project || '')}`,
+    };
+
+    const response = await rulesService.list(request);
+
+    const rules = response.rules || [];
+    const sortedRules = rules.sort((a, b)=> {
+      // These are RFC 3339-formatted date/time strings.
+      // Because they are all use the same timezone, and RFC 3339
+      // date/times are specified from most significant to least
+      // significant, any string sort that produces a lexicographical
+      // ordering should also sort by time.
+      return b.lastUpdateTime.localeCompare(a.lastUpdateTime);
+    });
+    return sortedRules;
+  });
+  if (isLoading) {
+    return <LinearProgress />;
+  }
+
+  if (isError || rules === undefined) {
+    return <ErrorAlert
+      errorText={`Got an error while loading rules: ${error}`}
+      errorTitle="Failed to load rules"
+      showError/>;
+  }
+
+  return (
+    <TableContainer component={Box}>
+      <Table data-testid="impact-table" size="small" sx={{ overflowWrap: 'anywhere' }}>
+        <TableHead>
+          <TableRow>
+            <TableCell>Rule Definition</TableCell>
+            <TableCell sx={{ width: '150px' }}>Bug</TableCell>
+            <TableCell sx={{ width: '100px' }}>Last Updated</TableCell>
+          </TableRow>
+        </TableHead>
+        <TableBody>
+          {
+            rules.map((rule) => (
+              <TableRow key={rule.ruleId}>
+                <TableCell><Link component={RouterLink} to={linkToRule(rule.project, rule.ruleId)} underline="hover">{rule.ruleDefinition}</Link></TableCell>
+                <TableCell><Link href={rule.bug.url} underline="hover">{rule.bug.linkText}</Link></TableCell>
+                <TableCell>{dayjs.utc(rule.lastUpdateTime).local().fromNow()}</TableCell>
+              </TableRow>
+            ))
+          }
+        </TableBody>
+      </Table>
+    </TableContainer>
+  );
+};
+
+export default RulesTable;
diff --git a/analysis/frontend/ui/src/components/timestamp_info_bar/styles.css b/analysis/frontend/ui/src/components/timestamp_info_bar/styles.css
new file mode 100644
index 0000000..bee9850
--- /dev/null
+++ b/analysis/frontend/ui/src/components/timestamp_info_bar/styles.css
@@ -0,0 +1,19 @@
+/**
+* Copyright 2022 The LUCI Authors.
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*      http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+.timestamp-text {
+  color: var(--greyed-out-text-color);
+}
diff --git a/analysis/frontend/ui/src/components/timestamp_info_bar/timestamp_info_bar.test.tsx b/analysis/frontend/ui/src/components/timestamp_info_bar/timestamp_info_bar.test.tsx
new file mode 100644
index 0000000..424e500
--- /dev/null
+++ b/analysis/frontend/ui/src/components/timestamp_info_bar/timestamp_info_bar.test.tsx
@@ -0,0 +1,73 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+  waitFor,
+} from '@testing-library/react';
+
+import { createDefaultMockRule } from '../../testing_tools/mocks/rule_mock';
+import TimestampInfoBar from './timestamp_info_bar';
+
+describe('Test TimestampInfoBar component', () => {
+  it('when proved with rule, then should render username and timestamps', async () => {
+    const rule = createDefaultMockRule();
+    render(<TimestampInfoBar
+      createUsername={rule.createUser}
+      createTime={rule.createTime}
+      updateUsername={rule.lastUpdateUser}
+      updateTime={rule.lastUpdateTime}/>);
+    await waitFor(() => screen.getByTestId('timestamp-info-bar-create'));
+
+    expect(screen.getByTestId('timestamp-info-bar-create'))
+        .toHaveTextContent('Created by Weetbix a few seconds ago');
+    expect(screen.getByTestId('timestamp-info-bar-update'))
+        .toHaveTextContent('Last modified by user@example.com a few seconds ago');
+  });
+
+  it('when provided with a google account, then should display name only', async () => {
+    const rule = createDefaultMockRule();
+    rule.createUser = 'googler@google.com';
+    render(<TimestampInfoBar
+      createUsername={rule.createUser}
+      createTime={rule.createTime}
+      updateUsername={rule.lastUpdateUser}
+      updateTime={rule.lastUpdateTime}/>);
+    await waitFor(() => screen.getByTestId('timestamp-info-bar-create'));
+
+    expect(screen.getByTestId('timestamp-info-bar-create'))
+        .toHaveTextContent('Created by googler a few seconds ago');
+    expect(screen.getByTestId('timestamp-info-bar-update'))
+        .toHaveTextContent('Last modified by user@example.com a few seconds ago');
+  });
+
+  it('when provided with an external user, then should use full username', async () => {
+    const rule = createDefaultMockRule();
+    rule.createUser = 'user@example.com';
+    render(<TimestampInfoBar
+      createUsername={rule.createUser}
+      createTime={rule.createTime}
+      updateUsername={rule.lastUpdateUser}
+      updateTime={rule.lastUpdateTime}/>);
+    await waitFor(() => screen.getByTestId('timestamp-info-bar-create'));
+
+    expect(screen.getByTestId('timestamp-info-bar-create'))
+        .toHaveTextContent('Created by user@example.com a few seconds ago');
+    expect(screen.getByTestId('timestamp-info-bar-update'))
+        .toHaveTextContent('Last modified by user@example.com a few seconds ago');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/timestamp_info_bar/timestamp_info_bar.tsx b/analysis/frontend/ui/src/components/timestamp_info_bar/timestamp_info_bar.tsx
new file mode 100644
index 0000000..6a27616
--- /dev/null
+++ b/analysis/frontend/ui/src/components/timestamp_info_bar/timestamp_info_bar.tsx
@@ -0,0 +1,75 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './styles.css';
+
+import dayjs from 'dayjs';
+
+import Grid from '@mui/material/Grid';
+import Link from '@mui/material/Link';
+
+interface Props {
+    createUsername: string | undefined;
+    createTime: string | undefined;
+    updateUsername: string | undefined;
+    updateTime: string | undefined;
+}
+
+interface FormattedUsernameProps {
+    username: string | undefined;
+}
+
+const FormattedUsername = ({ username }: FormattedUsernameProps) => {
+  if (!username) {
+    return <></>;
+  }
+  if (username == 'weetbix') {
+    return <>Weetbix</>;
+  } else if (username.endsWith('@google.com')) {
+    const ldap = username.substring(0, username.length - '@google.com'.length);
+    return <Link target="_blank" href={`http://who/${ldap}`}>{ldap}</Link>;
+  } else {
+    return <>{username}</>;
+  }
+};
+
+const dateFormat = 'LLLL';
+
+const TimestampInfoBar = ({
+  createUsername,
+  createTime,
+  updateUsername,
+  updateTime,
+}: Props) => {
+  return (
+    <Grid container>
+      <Grid item>
+        <small
+          title={dayjs.utc(createTime).local().format(dateFormat)}
+          data-testid="timestamp-info-bar-create"
+          className='timestamp-text'>
+            Created by {<FormattedUsername username={createUsername} />} {dayjs.utc(createTime).local().fromNow()}. |
+        </small>
+        <small
+          title={dayjs.utc(updateTime).local().format(dateFormat)}
+          data-testid="timestamp-info-bar-update"
+          className='timestamp-text'>
+          {' '}Last modified by {<FormattedUsername username={updateUsername} />} {dayjs.utc(updateTime).local().fromNow()}.
+        </small>
+      </Grid>
+    </Grid>
+  );
+};
+
+export default TimestampInfoBar;
diff --git a/analysis/frontend/ui/src/components/top_bar/collapsed_menu/collapsed_menu.test.tsx b/analysis/frontend/ui/src/components/top_bar/collapsed_menu/collapsed_menu.test.tsx
new file mode 100644
index 0000000..7597e55
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/collapsed_menu/collapsed_menu.test.tsx
@@ -0,0 +1,62 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import { Home } from '@mui/icons-material';
+import {
+  fireEvent,
+  screen,
+} from '@testing-library/react';
+
+import { renderWithRouter } from '../../../testing_tools/libs/mock_router';
+import { AppBarPage } from '../top_bar';
+import { TopBarContextProvider } from '../top_bar_context';
+import CollapsedMenu from './collapsed_menu';
+
+describe('test CollapsedMenu component', () => {
+  const pages: AppBarPage[] = [
+    {
+      title: 'Clusters',
+      url: '/Clusters',
+      icon: Home,
+    },
+  ];
+
+  it('given a set of pages, then should display them in a menu', async () => {
+    renderWithRouter(
+        <CollapsedMenu pages={pages}/>,
+    );
+
+    await screen.findByText('Weetbix');
+
+    expect(screen.getByText('Clusters')).toBeInTheDocument();
+  });
+
+  it('when clicking on menu button then the menu should be visible', async () => {
+    renderWithRouter(
+        <TopBarContextProvider >
+          <CollapsedMenu pages={pages}/>
+        </TopBarContextProvider>,
+    );
+
+    await screen.findByText('Weetbix');
+
+    expect(screen.getByTestId('collapsed-menu')).not.toBeVisible();
+
+    await fireEvent.click(screen.getByTestId('collapsed-menu-button'));
+
+    expect(screen.getByTestId('collapsed-menu')).toBeVisible();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/top_bar/collapsed_menu/collapsed_menu.tsx b/analysis/frontend/ui/src/components/top_bar/collapsed_menu/collapsed_menu.tsx
new file mode 100644
index 0000000..297891b
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/collapsed_menu/collapsed_menu.tsx
@@ -0,0 +1,123 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React, { useContext } from 'react';
+import { Link } from 'react-router-dom';
+
+import MenuIcon from '@mui/icons-material/Menu';
+import Box from '@mui/material/Box';
+import IconButton from '@mui/material/IconButton';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import Typography from '@mui/material/Typography';
+
+import { DynamicComponentNoProps } from '../../../tools/rendering_tools';
+import Logo from '../logo/logo';
+import { AppBarPage } from '../top_bar';
+import { TopBarContext } from '../top_bar_context';
+
+interface Props {
+  pages: AppBarPage[];
+}
+
+// A menu that is collapsed on mobile.
+const CollapsedMenu = ({ pages }: Props) => {
+  const { anchorElNav, setAnchorElNav } = useContext(TopBarContext);
+
+  const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
+    setAnchorElNav(event.currentTarget);
+  };
+
+  const handleCloseNavMenu = () => {
+    setAnchorElNav(null);
+  };
+
+  return (
+    <>
+      <Box sx={{ flexGrow: 1, display: { xs: 'flex', md: 'none' } }}>
+        <IconButton
+          size="large"
+          aria-label="pages menu"
+          aria-controls="menu-appbar"
+          aria-haspopup="true"
+          onClick={handleOpenNavMenu}
+          color="inherit"
+          data-testid="collapsed-menu-button"
+        >
+          <MenuIcon />
+        </IconButton>
+        <Menu
+          id="menu-appbar"
+          keepMounted
+          anchorEl={anchorElNav}
+          anchorOrigin={{
+            vertical: 'bottom',
+            horizontal: 'left',
+          }}
+          transformOrigin={{
+            vertical: 'top',
+            horizontal: 'left',
+          }}
+          open={Boolean(anchorElNav)}
+          onClose={handleCloseNavMenu}
+          sx={{
+            display: { xs: 'block', md: 'none' },
+          }}
+          data-testid="collapsed-menu"
+        >
+          {pages.map((page) => (
+            <MenuItem
+              component={Link}
+              to={page.url}
+              key={page.title}
+              onClick={handleCloseNavMenu}>
+              <ListItemIcon>
+                <DynamicComponentNoProps component={page.icon} />
+              </ListItemIcon>
+              <ListItemText>{page.title}</ListItemText>
+            </MenuItem>
+          ))}
+        </Menu>
+      </Box>
+      <Box sx={{
+        display: {
+          xs: 'flex',
+          md: 'none' },
+        mr: 1,
+        width: '3rem',
+      }}>
+        <Logo />
+      </Box>
+      <Typography
+        variant="h5"
+        noWrap
+        component="a"
+        href=""
+        sx={{
+          mr: 2,
+          display: { xs: 'flex', md: 'none' },
+          flexGrow: 1,
+          color: 'inherit',
+          textDecoration: 'none',
+        }}
+      >
+        Weetbix
+      </Typography>
+    </>
+  );
+};
+
+export default CollapsedMenu;
diff --git a/analysis/frontend/ui/src/components/top_bar/logo/logo.test.tsx b/analysis/frontend/ui/src/components/top_bar/logo/logo.test.tsx
new file mode 100644
index 0000000..6bd5b56
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/logo/logo.test.tsx
@@ -0,0 +1,32 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import '@testing-library/jest-dom';
+
+import {
+  render,
+  screen,
+} from '@testing-library/react';
+
+import Logo from './logo';
+
+describe('test Logo component', () => {
+  it('should display logo image', async () => {
+    render(
+        <Logo />,
+    );
+    await screen.findByRole('img');
+
+    expect(screen.getByRole('img')).toHaveAttribute('alt', 'logo');
+  });
+});
diff --git a/analysis/frontend/ui/src/components/top_bar/logo/logo.tsx b/analysis/frontend/ui/src/components/top_bar/logo/logo.tsx
new file mode 100644
index 0000000..17efde9
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/logo/logo.tsx
@@ -0,0 +1,27 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+
+const Logo = () => {
+  return (
+    <img
+      style={{ width: '100%' }}
+      alt="logo"
+      id="chromium-icon"
+      src="https://storage.googleapis.com/chrome-infra/lucy-small.png" />
+  );
+};
+
+export default React.memo(Logo);
diff --git a/analysis/frontend/ui/src/components/top_bar/top_bar.test.tsx b/analysis/frontend/ui/src/components/top_bar/top_bar.test.tsx
new file mode 100644
index 0000000..da19c28
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/top_bar.test.tsx
@@ -0,0 +1,54 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import '@testing-library/jest-dom';
+
+import { screen } from '@testing-library/react';
+
+import {
+  renderWithRouter,
+  renderWithRouterAndClient,
+} from '../../testing_tools/libs/mock_router';
+import TopBar from './top_bar';
+
+describe('test TopBar component', () => {
+  beforeAll(() => {
+    window.email = 'test@google.com';
+    window.avatar = '/example.png';
+    window.fullName = 'Test Name';
+    window.logoutUrl = '/logout';
+  });
+
+  it('should render logo and user email', async () => {
+    renderWithRouter(
+        <TopBar />,
+    );
+
+    await screen.findAllByText('Weetbix');
+
+    expect(screen.getByText(window.email)).toBeInTheDocument();
+  });
+
+  it('given a route with a project then should display pages', async () => {
+    renderWithRouterAndClient(
+        <TopBar />,
+        '/p/chrome',
+        '/p/:project',
+    );
+
+    await screen.findAllByText('Weetbix');
+
+    expect(screen.getAllByText('Clusters')).toHaveLength(2);
+    expect(screen.getAllByText('Rules')).toHaveLength(2);
+  });
+});
diff --git a/analysis/frontend/ui/src/components/top_bar/top_bar.tsx b/analysis/frontend/ui/src/components/top_bar/top_bar.tsx
new file mode 100644
index 0000000..34c01ac
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/top_bar.tsx
@@ -0,0 +1,131 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  useContext,
+  useMemo,
+} from 'react';
+import {
+  Link,
+  useParams,
+} from 'react-router-dom';
+
+import RuleIcon from '@mui/icons-material/Rule';
+import SpokeIcon from '@mui/icons-material/Spoke';
+import AppBar from '@mui/material/AppBar';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+// eslint-disable-next-line import/no-unresolved
+import { OverridableComponent } from '@mui/material/OverridableComponent';
+import { SvgIconTypeMap } from '@mui/material/SvgIcon';
+import Toolbar from '@mui/material/Toolbar';
+import Typography from '@mui/material/Typography';
+
+import { DynamicComponentNoProps } from '../../tools/rendering_tools';
+import CollapsedMenu from './collapsed_menu/collapsed_menu';
+import Logo from './logo/logo';
+import {
+  TopBarContext,
+  TopBarContextProvider,
+} from './top_bar_context';
+import UserActions from './user_actions/user_actions';
+
+type AppBarPageTitle = 'Clusters' | 'Rules';
+
+export interface AppBarPage {
+  title: AppBarPageTitle;
+  url: string;
+  icon: OverridableComponent<SvgIconTypeMap>;
+}
+
+function generatePages(projectId: string | undefined): AppBarPage[] {
+  if (!projectId) {
+    return [];
+  }
+  return [
+    {
+      title: 'Clusters',
+      url: `/p/${projectId}/clusters`,
+      icon: SpokeIcon,
+    },
+    {
+      title: 'Rules',
+      url: `/p/${projectId}/rules`,
+      icon: RuleIcon,
+    },
+  ];
+}
+
+const TopBar = () => {
+  const { setAnchorElNav } = useContext(TopBarContext);
+
+  const handleCloseNavMenu = () => {
+    setAnchorElNav(null);
+  };
+
+  const { project: projectId } = useParams();
+
+  const pages = useMemo<AppBarPage[]>(() => generatePages(projectId), [projectId]);
+
+  return (
+    <TopBarContextProvider>
+      <AppBar position="static">
+        <Toolbar >
+          <Box sx={{
+            display: {
+              xs: 'none',
+              md: 'flex',
+            },
+            mr: 1,
+            width: '3rem',
+          }}>
+            <Logo />
+          </Box>
+          <Typography
+            variant="h6"
+            noWrap
+            component="a"
+            href="/"
+            sx={{
+              mr: 2,
+              display: { xs: 'none', md: 'flex' },
+              color: 'inherit',
+              textDecoration: 'none',
+            }}
+          >
+            Weetbix
+          </Typography>
+          <CollapsedMenu pages={pages}/>
+          <Box sx={{ flexGrow: 1, display: { xs: 'none', md: 'flex' } }}>
+            {pages.map((page) => (
+              <Button
+                component={Link}
+                to={page.url}
+                key={page.title}
+                onClick={handleCloseNavMenu}
+                sx={{ color: 'white' }}
+                startIcon={<DynamicComponentNoProps component={page.icon}/>}
+              >
+                {page.title}
+              </Button>
+            ))}
+          </Box>
+          <UserActions />
+        </Toolbar>
+      </AppBar>
+    </TopBarContextProvider>
+  );
+};
+
+export default TopBar;
diff --git a/analysis/frontend/ui/src/components/top_bar/top_bar_context.tsx b/analysis/frontend/ui/src/components/top_bar/top_bar_context.tsx
new file mode 100644
index 0000000..0c8bc75
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/top_bar_context.tsx
@@ -0,0 +1,46 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  createContext,
+  Dispatch,
+  SetStateAction,
+  useState,
+} from 'react';
+
+type AnchorElNav = null | HTMLElement;
+
+export interface TopBarContextState {
+  anchorElNav: AnchorElNav;
+  setAnchorElNav: Dispatch<SetStateAction<AnchorElNav>>
+}
+
+export const TopBarContext = createContext<TopBarContextState>({
+  anchorElNav: null,
+  // eslint-disable-next-line @typescript-eslint/no-empty-function
+  setAnchorElNav: () => {},
+});
+
+interface Props {
+  children: React.ReactNode;
+}
+
+export const TopBarContextProvider = ({ children }: Props) => {
+  const [anchorElNav, setAnchorElNav] = useState<AnchorElNav>(null);
+  return (
+    <TopBarContext.Provider value={{ anchorElNav, setAnchorElNav }}>
+      {children}
+    </TopBarContext.Provider>
+  );
+};
diff --git a/analysis/frontend/ui/src/components/top_bar/user_actions/user_actions.test.tsx b/analysis/frontend/ui/src/components/top_bar/user_actions/user_actions.test.tsx
new file mode 100644
index 0000000..68912f9
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/user_actions/user_actions.test.tsx
@@ -0,0 +1,58 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@testing-library/jest-dom';
+
+import {
+  fireEvent,
+  render,
+  screen,
+} from '@testing-library/react';
+
+import UserActions from './user_actions';
+
+describe('test UserActions component', () => {
+  beforeAll(() => {
+    window.email = 'test@google.com';
+    window.avatar = '/example.png';
+    window.fullName = 'Test Name';
+    window.logoutUrl = '/logout';
+  });
+
+  it('should display user email and logout url', async () => {
+    render(
+        <UserActions />,
+    );
+
+    await screen.getByText(window.email);
+
+    expect(screen.getByRole('img')).toHaveAttribute('src', window.avatar);
+    expect(screen.getByRole('img')).toHaveAttribute('alt', window.fullName);
+    expect(screen.getByTestId('useractions_logout')).toHaveAttribute('href', window.logoutUrl);
+  });
+
+  it('when clicking on email button then should display logout url', async () => {
+    render(
+        <UserActions />,
+    );
+
+    await screen.getByText(window.email);
+
+    expect(screen.getByTestId('user-settings-menu')).not.toBeVisible();
+
+    await fireEvent.click(screen.getByText(window.email));
+
+    expect(screen.getByTestId('user-settings-menu')).toBeVisible();
+  });
+});
diff --git a/analysis/frontend/ui/src/components/top_bar/user_actions/user_actions.tsx b/analysis/frontend/ui/src/components/top_bar/user_actions/user_actions.tsx
new file mode 100644
index 0000000..f388711
--- /dev/null
+++ b/analysis/frontend/ui/src/components/top_bar/user_actions/user_actions.tsx
@@ -0,0 +1,78 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import React from 'react';
+
+// import AccountCircleIcon from '@mui/icons-material/AccountCircle';
+import LogoutIcon from '@mui/icons-material/Logout';
+import { Avatar } from '@mui/material';
+import Box from '@mui/material/Box';
+import Button from '@mui/material/Button';
+import MuiLink from '@mui/material/Link';
+import ListItemIcon from '@mui/material/ListItemIcon';
+import ListItemText from '@mui/material/ListItemText';
+import Menu from '@mui/material/Menu';
+import MenuItem from '@mui/material/MenuItem';
+import Tooltip from '@mui/material/Tooltip';
+
+const UserActions = () => {
+  const [anchorElUser, setAnchorElUser] = React.useState<null | HTMLElement>(null);
+
+  const handleCloseUserMenu = () => {
+    setAnchorElUser(null);
+  };
+
+  const handleOpenUserMenu = (event: React.MouseEvent<HTMLElement>) => {
+    setAnchorElUser(event.currentTarget);
+  };
+
+  return (
+    <Box sx={{ flexGrow: 0, display: 'flex', alignItems: 'center' }}>
+      <Tooltip title="Open settings">
+        <Button
+          onClick={handleOpenUserMenu}
+          sx={{ color: 'white', textTransform: 'none' }}
+          endIcon={<Avatar src={window.avatar} alt={window.fullName}/>}>
+          {window.email}
+        </Button>
+      </Tooltip>
+      <Menu
+        sx={{ mt: '45px' }}
+        id="menu-appbar"
+        data-testid="user-settings-menu"
+        anchorEl={anchorElUser}
+        anchorOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        keepMounted
+        transformOrigin={{
+          vertical: 'top',
+          horizontal: 'right',
+        }}
+        open={Boolean(anchorElUser)}
+        onClose={handleCloseUserMenu}
+      >
+        <MenuItem data-testid="useractions_logout" component={MuiLink} href={window.logoutUrl} onClick={handleCloseUserMenu}>
+          <ListItemIcon>
+            <LogoutIcon fontSize="small" />
+          </ListItemIcon>
+          <ListItemText>Logout</ListItemText>
+        </MenuItem>
+      </Menu>
+    </Box>
+  );
+};
+
+export default UserActions;
diff --git a/analysis/frontend/ui/src/context/snackbar_context.tsx b/analysis/frontend/ui/src/context/snackbar_context.tsx
new file mode 100644
index 0000000..a6af3a3
--- /dev/null
+++ b/analysis/frontend/ui/src/context/snackbar_context.tsx
@@ -0,0 +1,59 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  createContext,
+  Dispatch,
+  SetStateAction,
+  useState,
+} from 'react';
+
+import { AlertColor } from '@mui/material/Alert';
+
+export interface Snack {
+    open?: boolean;
+    message?: string;
+    severity?: AlertColor;
+}
+
+export interface SnackbarContextData {
+    snack: Snack;
+    setSnack: Dispatch<SetStateAction<Snack>>;
+}
+
+export const snackContextDefaultState: Snack = {
+  open: false,
+  message: '',
+  severity: 'success',
+};
+
+
+export const SnackbarContext = createContext<SnackbarContextData>({
+  snack: snackContextDefaultState,
+  // eslint-disable-next-line @typescript-eslint/no-empty-function
+  setSnack: () => {},
+});
+
+interface Props {
+    children: React.ReactNode;
+}
+
+export const SnackbarContextWrapper = ({ children }: Props) => {
+  const [snack, setSnack] = useState<Snack>(snackContextDefaultState);
+  return (
+    <SnackbarContext.Provider value={{ snack, setSnack }}>
+      {children}
+    </SnackbarContext.Provider>
+  );
+};
diff --git a/analysis/frontend/ui/src/hooks/useFetchRule.ts b/analysis/frontend/ui/src/hooks/useFetchRule.ts
new file mode 100644
index 0000000..30a58e9
--- /dev/null
+++ b/analysis/frontend/ui/src/hooks/useFetchRule.ts
@@ -0,0 +1,28 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useQuery } from 'react-query';
+import { getRulesService } from '../services/rules';
+
+const useFetchRule = (ruleId: string | undefined, project: string | undefined) => {
+  const rulesService = getRulesService();
+
+  return useQuery(['rule', project, ruleId], async () => await rulesService.get(
+      {
+        name: `projects/${project}/rules/${ruleId}`,
+      },
+  ));
+};
+
+export default useFetchRule;
diff --git a/analysis/frontend/ui/src/hooks/useMutateRule.ts b/analysis/frontend/ui/src/hooks/useMutateRule.ts
new file mode 100644
index 0000000..c705424
--- /dev/null
+++ b/analysis/frontend/ui/src/hooks/useMutateRule.ts
@@ -0,0 +1,60 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useContext } from 'react';
+import {
+  useMutation,
+  useQueryClient,
+} from 'react-query';
+
+import { SnackbarContext } from '../context/snackbar_context';
+import {
+  getRulesService,
+  UpdateRuleRequest,
+} from '../services/rules';
+
+type MutationCallback = () => void;
+
+export const useMutateRule = (
+    successCallback?: MutationCallback,
+    errorCallback?: MutationCallback,
+) => {
+  const ruleService = getRulesService();
+  const queryClient = useQueryClient();
+  const { setSnack } = useContext(SnackbarContext);
+
+  return useMutation((updateRuleRequest: UpdateRuleRequest) => ruleService.update(updateRuleRequest), {
+    onSuccess: (data) => {
+      queryClient.setQueryData(['rule', data.project, data.ruleId], data);
+      setSnack({
+        open: true,
+        message: 'Rule updated successfully',
+        severity: 'success',
+      });
+      if (successCallback) {
+        successCallback();
+      }
+    },
+    onError: (error) => {
+      setSnack({
+        open: true,
+        message: `Failed to mutate rule due to: ${error}`,
+        severity: 'error',
+      });
+      if (errorCallback) {
+        errorCallback();
+      }
+    },
+  });
+};
diff --git a/analysis/frontend/ui/src/layouts/base.tsx b/analysis/frontend/ui/src/layouts/base.tsx
new file mode 100644
index 0000000..c58358c
--- /dev/null
+++ b/analysis/frontend/ui/src/layouts/base.tsx
@@ -0,0 +1,37 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { Outlet } from 'react-router-dom';
+
+import TopBar from '../components/top_bar/top_bar';
+
+declare global {
+    interface Window {
+      avatar: string;
+      fullName: string;
+      email: string;
+      logoutUrl: string;
+    }
+}
+
+const BaseLayout = () => {
+  return (
+    <>
+      <TopBar />
+      <Outlet />
+    </>
+  );
+};
+
+export default BaseLayout;
diff --git a/analysis/frontend/ui/src/services/cluster.ts b/analysis/frontend/ui/src/services/cluster.ts
new file mode 100644
index 0000000..9584759
--- /dev/null
+++ b/analysis/frontend/ui/src/services/cluster.ts
@@ -0,0 +1,323 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { AuthorizedPrpcClient } from '../clients/authorized_client';
+import { AssociatedBug, ClusterId } from './shared_models';
+
+export const getClustersService = () => {
+  const client = new AuthorizedPrpcClient();
+  return new ClustersService(client);
+};
+
+// A service to handle cluster-related gRPC requests.
+export class ClustersService {
+  private static SERVICE = 'weetbix.v1.Clusters';
+
+  client: AuthorizedPrpcClient;
+
+  constructor(client: AuthorizedPrpcClient) {
+    this.client = client;
+  }
+
+  async batchGet(request: BatchGetClustersRequest): Promise<BatchGetClustersResponse> {
+    return this.client.call(ClustersService.SERVICE, 'BatchGet', request);
+  }
+
+  async getReclusteringProgress(request: GetReclusteringProgressRequest): Promise<ReclusteringProgress> {
+    return this.client.call(ClustersService.SERVICE, 'GetReclusteringProgress', request);
+  }
+
+  async queryClusterSummaries(request: QueryClusterSummariesRequest): Promise<QueryClusterSummariesResponse> {
+    return this.client.call(ClustersService.SERVICE, 'QueryClusterSummaries', request);
+  }
+
+  async queryClusterFailures(request: QueryClusterFailuresRequest): Promise<QueryClusterFailuresResponse> {
+    return this.client.call(ClustersService.SERVICE, 'QueryClusterFailures', request);
+  }
+}
+
+export interface BatchGetClustersRequest {
+  // The LUCI project shared by all clusters to retrieve.
+  // Required.
+  // Format: projects/{project}.
+  parent: string;
+
+  // The resource name of the clusters retrieve.
+  // Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}.
+  // At most 1,000 clusters may be requested at a time.
+  names: string[];
+}
+
+export interface BatchGetClustersResponse {
+  clusters?: Cluster[];
+}
+
+export interface Cluster {
+  // The resource name of the cluster.
+  // Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}.
+  name: string;
+  // Whether there is a recent example in the cluster.
+  hasExample?: boolean;
+  // A human-readable name for the cluster.
+  // Only populated for suggested clusters where has_example = true.
+  title?: string;
+  // The total number of user changelists which failed presubmit.
+  userClsFailedPresubmit: MetricValues;
+  // The total number of failures in the cluster that occurred on tryjobs
+  // that were critical (presubmit-blocking) and were exonerated for a
+  // reason other than NOT_CRITICAL or UNEXPECTED_PASS.
+  criticalFailuresExonerated: MetricValues;
+  // The total number of failures in the cluster.
+  failures: MetricValues;
+  // The failure association rule equivalent to the cluster. Populated only
+  // for suggested clusters where has_example = true; for rule-based
+  // clusters, lookup the rule instead. Used to facilitate creating a new
+  // rule based on this cluster.
+  equivalentFailureAssociationRule: string | undefined;
+}
+
+export interface MetricValues {
+  // The impact for the last day.
+  oneDay: Counts;
+  // The impact for the last three days.
+  threeDay: Counts;
+  // The impact for the last week.
+  sevenDay: Counts;
+}
+
+export interface Counts {
+  // The value of the metric (summed over all failures).
+  // 64-bit integer serialized as a string.
+  nominal?: string;
+}
+
+export interface GetReclusteringProgressRequest {
+  // The name of the reclustering progress resource.
+  // Format: projects/{project}/reclusteringProgress.
+  name: string;
+}
+
+// ReclusteringProgress captures the progress re-clustering a
+// given LUCI project's test results with a specific rules
+// version and/or algorithms version.
+export interface ReclusteringProgress {
+  // ProgressPerMille is the progress of the current re-clustering run,
+  // measured in thousandths (per mille).
+  progressPerMille?: number;
+  // Last is the goal of the last completed re-clustering run.
+  last: ClusteringVersion;
+  // Next is the goal of the current re-clustering run. (For which
+  // ProgressPerMille is specified.)
+  // It may be the same as the goal of the last completed reclustering run.
+  next: ClusteringVersion;
+}
+
+// ClusteringVersion captures the rules and algorithms a re-clustering run
+// is re-clustering to.
+export interface ClusteringVersion {
+  rulesVersion: string; // RFC 3339 encoded date/time.
+  configVersion: string; // RFC 3339 encoded date/time.
+  algorithmsVersion: number;
+}
+
+export interface QueryClusterSummariesRequest {
+  // The LUCI project.
+  project: string;
+
+  // An AIP-160 style filter on the failures that are used as input to
+  // clustering.
+  failureFilter: string;
+
+  // An AIP-132 style order_by clause, which specifies the sort order
+  // of the result.
+  orderBy: string;
+}
+
+export type SortableMetricName = 'presubmit_rejects' | 'critical_failures_exonerated' | 'failures';
+
+export interface QueryClusterSummariesResponse {
+  clusterSummaries?: ClusterSummary[];
+}
+
+export interface ClusterSummary {
+  // The identity of the cluster.
+  clusterId: ClusterId;
+  // A one-line description of the cluster.
+  title: string;
+  // The bug associated with the cluster. This is only present for
+  // clusters defined by failure association rules.
+  bug?: AssociatedBug;
+  // The number of distinct user CLs rejected by the cluster.
+  // 64-bit integer serialized as a string.
+  presubmitRejects?: string;
+  // The number of failures that were critical (on builders critical
+  // to CQ succeeding and not exonerated for non-criticality)
+  // and exonerated.
+  // 64-bit integer serialized as a string.
+  criticalFailuresExonerated?: string;
+  // The total number of test results in the cluster.
+  // 64-bit integer serialized as a string.
+  failures?: string;
+}
+
+export interface QueryClusterFailuresRequest {
+  // The resource name of the cluster to retrieve failures for.
+  // Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}/failures.
+  parent: string;
+}
+
+export interface QueryClusterFailuresResponse {
+  // Example failures in the cluster. Limited to 2000 rows.
+  failures?: DistinctClusterFailure[];
+}
+
+// The reason a test result was exonerated.
+export type ExonerationReason =
+    // The exoneration reason is not known to Weetbix.
+    'EXONERATION_REASON_UNSPECIFIED'
+    // Similar unexpected results were observed in presubmit run(s) for other,
+    // unrelated CL(s). (This is suggestive of the issue being present
+    // on mainline but is not confirmed as there are possible confounding
+    // factors, like how tests are run on CLs vs how tests are run on
+    // mainline branches.)
+    // Applies to unexpected results in presubmit/CQ runs only.
+    | 'OCCURS_ON_OTHER_CLS'
+    // Similar unexpected results were observed on a mainline branch
+    // (i.e. against a build without unsubmitted changes applied).
+    // (For avoidance of doubt, this includes both flakily and
+    // deterministically occurring unexpected results.)
+    // Applies to unexpected results in presubmit/CQ runs only.
+    | 'OCCURS_ON_MAINLINE'
+    // The tests are not critical to the test subject (e.g. CL) passing.
+    // This could be because more data is being collected to determine if
+    // the tests are stable enough to be made critical (as is often the
+    // case for experimental test suites).
+    | 'NOT_CRITICAL'
+    // The test variant was exonerated because it contained an unexpected
+    // pass.
+    | 'UNEXPECTED_PASS';
+
+// Refer to weetbix.v1.PresubmitRunMode for documentation.
+export type PresubmitRunMode =
+    'PRESUBMIT_RUN_MODE_UNSPECIFIED'
+    | 'DRY_RUN'
+    | 'FULL_RUN'
+    | 'QUICK_DRY_RUN';
+
+// Refer to weetbix.v1.BuildStatus for documentation.
+export type BuildStatus =
+    'BUILD_STATUS_UNSPECIFIED'
+    | 'BUILD_STATUS_SUCCESS'
+    | 'BUILD_STATUS_FAILURE'
+    | 'BUILD_STATUS_INFRA_FAILURE'
+    | 'BUILD_STATUS_CANCELED';
+
+// Refer to weetbix.v1.ClusterFailureGroup.Exoneration for documentation.
+export interface Exoneration {
+  // The machine-readable reason for the exoneration.
+  reason: ExonerationReason;
+}
+
+// Key/Value Variant pair that describes (part of) a way to run a test.
+export interface VariantPair {
+  key?: string;
+  value?: string;
+}
+
+export interface VariantDef {
+  [key: string]: string | undefined;
+}
+
+export interface Variant {
+  def: VariantDef;
+}
+
+// Identity of a presubmit run.
+// Refer to weetbix.v1.PresubmitRunId for documentation.
+export interface PresubmitRunId {
+  system?: string;
+  id?: string;
+}
+
+// Refer to weetbix.v1.ClusterFailureGroup.PresubmitRun for documentation.
+export interface PresubmitRun {
+  // Identity of the presubmit run that contains this test result.
+  presubmitRunId: PresubmitRunId;
+  // The owner of the presubmit run (if any).
+  owner: string;
+  // The mode of the presubmit run.
+  mode: PresubmitRunMode;
+}
+
+export interface Changelist {
+  // Gerrit hostname, e.g. "chromium-review.googlesource.com".
+  host: string;
+
+  // Change number, encoded as a string, e.g. "12345".
+  change: string;
+
+  // Patchset number, e.g. 1.
+  patchset: number;
+}
+
+// Refer to weetbix.v1.DistinctClusterFailure for documentation.
+export interface DistinctClusterFailure {
+  // The identity of the test.
+  testId: string;
+
+  // The test variant. Describes a way of running a test.
+  variant?: Variant;
+
+  partitionTime: string; // RFC 3339 encoded date/time.
+
+  presubmitRun?: PresubmitRun;
+
+  // Whether the build was critical to a presubmit run succeeding.
+  // If the build was not part of a presubmit run, this field should
+  // be ignored.
+  isBuildCritical?: boolean;
+
+  // The exonerations applied to the test variant verdict.
+  exonerations?: Exoneration[];
+
+  // The status of the build that contained this test result. Can be used
+  // to filter incomplete results (e.g. where build was cancelled or had
+  // an infra failure). Can also be used to filter builds with incomplete
+  // exonerations (e.g. build succeeded but some tests not exonerated).
+  // This is the build corresponding to ingested_invocation_id.
+  buildStatus: BuildStatus;
+
+  // The invocation from which this test result was ingested. This is
+  // the top-level invocation that was ingested, an "invocation" being
+  // a container of test results as identified by the source test result
+  // system.
+  //
+  // For ResultDB, Weetbix ingests invocations corresponding to
+  // buildbucket builds.
+  ingestedInvocationId: string;
+
+  // Is the ingested invocation blocked by this test variant? This is
+  // only true if all (non-skipped) test results for this test variant
+  // (in the ingested invocation) are unexpected failures.
+  //
+  // Exoneration does not factor into this value; check exonerations
+  // to see if the impact of this ingested invocation being blocked was
+  // mitigated by exoneration.
+  isIngestedInvocationBlocked?: boolean;
+
+  changelists?: Changelist[];
+
+  // The number of test results in the group.
+  count : number;
+}
diff --git a/analysis/frontend/ui/src/services/monorail.ts b/analysis/frontend/ui/src/services/monorail.ts
new file mode 100644
index 0000000..b8b26d3
--- /dev/null
+++ b/analysis/frontend/ui/src/services/monorail.ts
@@ -0,0 +1,68 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { AuthorizedPrpcClient } from '../clients/authorized_client';
+
+declare global {
+    interface Window { monorailHostname: string | undefined; }
+}
+
+export function getIssuesService() : IssuesService {
+  const useIDToken = true;
+  if (!window.monorailHostname) {
+    throw new Error('monorail hostname not set');
+  }
+  const client = new AuthorizedPrpcClient('api-dot-' + window.monorailHostname, useIDToken);
+  return new IssuesService(client);
+}
+
+/**
+ * Provides access to the monorail issues service over pRPC.
+ * For handling errors, import:
+ * import { GrpcError } from '@chopsui/prpc-client';
+ */
+export class IssuesService {
+  private static SERVICE = 'monorail.v3.Issues';
+
+  client: AuthorizedPrpcClient;
+
+  constructor(client: AuthorizedPrpcClient) {
+    this.client = client;
+  }
+
+  async getIssue(request: GetIssueRequest) : Promise<Issue> {
+    return this.client.call(IssuesService.SERVICE, 'GetIssue', request, {});
+  }
+}
+
+export interface GetIssueRequest {
+    // The name of the issue to request.
+    // Format: projects/{project}/issues/{issue_id}.
+    name: string;
+}
+
+export interface StatusValue {
+    status: string;
+    derivation: string;
+}
+
+// Definition here is partial. Full definition here:
+// https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/api_proto/issue_objects.proto
+export interface Issue {
+    name: string;
+    summary: string;
+    status: StatusValue;
+    reporter: string;
+    modifyTime: string; // RFC 3339 encoded date/time.
+}
diff --git a/analysis/frontend/ui/src/services/project.ts b/analysis/frontend/ui/src/services/project.ts
new file mode 100644
index 0000000..86c5c13
--- /dev/null
+++ b/analysis/frontend/ui/src/services/project.ts
@@ -0,0 +1,77 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { AuthorizedPrpcClient } from '../clients/authorized_client';
+
+export const getProjectsService = () => {
+  const client = new AuthorizedPrpcClient();
+  return new ProjectService(client);
+};
+
+// A service to handle projects related gRPC requests.
+export class ProjectService {
+  private static SERVICE = 'weetbix.v1.Projects';
+
+  client: AuthorizedPrpcClient;
+
+  constructor(client: AuthorizedPrpcClient) {
+    this.client = client;
+  }
+
+  async getConfig(request: GetProjectConfigRequest): Promise<ProjectConfig> {
+    return this.client.call(ProjectService.SERVICE, 'GetConfig', request);
+  }
+
+  async list(request: ListProjectsRequest): Promise<ListProjectsResponse> {
+    return this.client.call(ProjectService.SERVICE, 'List', request);
+  }
+}
+
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface ListProjectsRequest {}
+
+export interface Project {
+    // The format is: `projects/{project}`.
+    name: string;
+    displayName: string;
+    project: string,
+}
+
+export interface ListProjectsResponse {
+    projects?: Project[];
+}
+
+export interface GetProjectConfigRequest {
+  // The format is: `projects/{project}/config`.
+  name: string;
+}
+
+// See weetbix.v1.Projects.GetProjectConfigResponse.Monorail for documentation.
+export interface Monorail {
+  // The monorail project used for this LUCI project.
+  project: string;
+
+  // The shortlink format used for this bug tracker.
+  // For example, "crbug.com".
+  displayPrefix: string;
+}
+
+// See weetbix.v1.Projects.ProjectConfig for documentation.
+export interface ProjectConfig {
+  // The format is: `projects/{project}/config`.
+  name: string;
+
+  // Details about the monorail project used for this LUCI project.
+  monorail: Monorail;
+}
diff --git a/analysis/frontend/ui/src/services/rules.ts b/analysis/frontend/ui/src/services/rules.ts
new file mode 100644
index 0000000..b1a61b9
--- /dev/null
+++ b/analysis/frontend/ui/src/services/rules.ts
@@ -0,0 +1,152 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { AuthorizedPrpcClient } from '../clients/authorized_client';
+import { AssociatedBug, ClusterId } from './shared_models';
+
+export const getRulesService = () : RulesService => {
+  const client = new AuthorizedPrpcClient();
+  return new RulesService(client);
+};
+
+// For handling errors, import:
+// import { GrpcError } from '@chopsui/prpc-client';
+export class RulesService {
+  private static SERVICE = 'weetbix.v1.Rules';
+
+  client: AuthorizedPrpcClient;
+
+  constructor(client: AuthorizedPrpcClient) {
+    this.client = client;
+  }
+
+  async get(request: GetRuleRequest) : Promise<Rule> {
+    return this.client.call(RulesService.SERVICE, 'Get', request, {});
+  }
+
+  async list(request: ListRulesRequest): Promise<ListRulesResponse> {
+    return this.client.call(RulesService.SERVICE, 'List', request, {});
+  }
+
+  async create(request: CreateRuleRequest): Promise<Rule> {
+    return this.client.call(RulesService.SERVICE, 'Create', request, {});
+  }
+
+  async update(request: UpdateRuleRequest): Promise<Rule> {
+    return this.client.call(RulesService.SERVICE, 'Update', request, {});
+  }
+
+  async lookupBug(request: LookupBugRequest): Promise<LookupBugResponse> {
+    return this.client.call(RulesService.SERVICE, 'LookupBug', request, {});
+  }
+}
+
+export interface GetRuleRequest {
+    // The name of the rule to retrieve.
+    // Format: projects/{project}/rules/{rule_id}.
+    name: string;
+}
+
+export interface Rule {
+    name: string;
+    project: string;
+    ruleId: string;
+    ruleDefinition: string;
+    bug: AssociatedBug;
+    isActive: boolean;
+    isManagingBug: boolean;
+    sourceCluster: ClusterId;
+    createTime: string; // RFC 3339 encoded date/time.
+    createUser: string;
+    lastUpdateTime: string; // RFC 3339 encoded date/time.
+    lastUpdateUser: string;
+    predicateLastUpdateTime: string; // RFC 3339 encoded date/time.
+    etag: string;
+}
+
+export interface ListRulesRequest {
+    // The parent, which owns this collection of rules.
+    // Format: projects/{project}.
+    parent: string;
+}
+
+export interface ListRulesResponse {
+    rules?: Rule[];
+}
+
+export interface CreateRuleRequest {
+    parent: string;
+    rule: RuleToCreate;
+}
+
+export interface RuleToCreate {
+    ruleDefinition: string;
+    bug: AssociatedBugToUpdate;
+    isActive?: boolean;
+    isManagingBug?: boolean;
+    sourceCluster?: ClusterId;
+}
+
+export interface AssociatedBugToUpdate {
+    system: string;
+    id: string;
+}
+
+export interface UpdateRuleRequest {
+    rule: RuleToUpdate;
+    // Comma separated list of fields to be updated.
+    // e.g. ruleDefinition,bug,isActive.
+    updateMask: string;
+    etag?: string;
+}
+
+export interface RuleToUpdate {
+    name: string;
+    ruleDefinition?: string;
+    bug?: AssociatedBugToUpdate;
+    isActive?: boolean;
+    isManagingBug?: boolean;
+    sourceCluster?: ClusterId;
+}
+
+export interface LookupBugRequest {
+    system: string;
+    id: string;
+}
+
+export interface LookupBugResponse {
+    // The looked up rules.
+    // Format: projects/{project}/rules/{rule_id}.
+    rules?: string[];
+}
+
+const ruleNameRE = /^projects\/(.*)\/rules\/(.*)$/;
+
+// RuleKey represents the key parts of a rule resource name.
+export interface RuleKey {
+    project: string;
+    ruleId: string;
+}
+
+// parseRuleName parses a rule resource name into its key parts.
+export const parseRuleName = (name: string):RuleKey => {
+  const results = name.match(ruleNameRE);
+  if (results == null) {
+    throw new Error('invalid rule resource name: ' + name);
+  }
+  return {
+    project: results[1],
+    ruleId: results[2],
+  };
+};
diff --git a/analysis/frontend/ui/src/services/shared_models.ts b/analysis/frontend/ui/src/services/shared_models.ts
new file mode 100644
index 0000000..8b12fb0
--- /dev/null
+++ b/analysis/frontend/ui/src/services/shared_models.ts
@@ -0,0 +1,27 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Contains data models shared between multiple services.
+
+export interface ClusterId {
+    algorithm: string;
+    id: string;
+}
+
+export interface AssociatedBug {
+    system: string;
+    id: string;
+    linkText: string;
+    url: string;
+}
diff --git a/analysis/frontend/ui/src/shared_elements/bug_picker.ts b/analysis/frontend/ui/src/shared_elements/bug_picker.ts
new file mode 100644
index 0000000..4805634
--- /dev/null
+++ b/analysis/frontend/ui/src/shared_elements/bug_picker.ts
@@ -0,0 +1,155 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { LitElement, html, customElement, property, css, state } from 'lit-element';
+
+import { Select } from '@material/mwc-select';
+import { TextField } from '@material/mwc-textfield';
+import '@material/mwc-list';
+import '@material/mwc-list/mwc-list-item';
+import {
+  getProjectsService,
+  GetProjectConfigRequest,
+  ProjectConfig,
+} from '../services/project';
+
+// BugPicker lists the failure association rules configured in Weetbix.
+@customElement('bug-picker')
+export class BugPicker extends LitElement {
+    @property()
+    project = '';
+
+    // The bug tracking system. Valid values are 'monorail' or 'buganizer'.
+    @property()
+    bugSystem = '';
+
+    // The bug ID within the bug tracking system.
+    // For monorail, the scheme is '{monorail_project}/{bug_number}'.
+    // For buganizer, the scheme is '{bug_number}'.
+    @property()
+    bugId = '';
+
+    @state()
+    projectConfig : ProjectConfig | null = null;
+
+    // Implements the workaround for mwc-select inside of an mwc-dialog, as
+    // described in
+    // https://github.com/material-components/material-web/issues/832.
+    @property({type: Boolean})
+    material832Workaround = false;
+
+    connectedCallback() {
+        super.connectedCallback();
+        this.fetch();
+    }
+
+    async fetch() {
+        if (!this.project) {
+            throw new Error('invariant violated: project must be set before fetch');
+        }
+        const projectsService = getProjectsService();
+        const request: GetProjectConfigRequest = {
+            name: `projects/${encodeURIComponent(this.project)}/config`,
+        };
+        this.projectConfig = await projectsService.getConfig(request);
+        if (this.bugSystem == '') {
+            // Default the bug tracking system.
+            this.setSystemMonorail();
+        }
+        this.requestUpdate();
+    }
+
+    render() {
+        let bugNumber = this.bugNumber();
+        let monorailSystem = this.monorailSystem();
+
+        return html`
+            <mwc-select ?fixedMenuPosition=${this.material832Workaround} id="bug-system" required label="Bug Tracker" data-cy="bug-system-dropdown" @change=${this.onSystemChange} @closed=${this.onSelectClosed}>
+                ${this.projectConfig != null ? html`<mwc-list-item value="monorail" .selected=${this.bugSystem == 'monorail' && monorailSystem == this.projectConfig.monorail.project}>${this.projectConfig.monorail.displayPrefix}</mwc-list-item>` : null }
+            </mwc-select>
+            <mwc-textfield id="bug-number" pattern="[0-9]{1,16}" required label="Bug Number" data-cy="bug-number-textbox" .value=${bugNumber} @change=${this.onNumberChange}></mwc-textfield>
+        `;
+    }
+
+    monorailSystem(): string | null {
+        if (this.bugId.indexOf('/') >= 0) {
+            let parts = this.bugId.split('/');
+            return parts[0];
+        } else {
+            return null;
+        }
+    }
+
+    bugNumber(): string {
+        if (this.bugId.indexOf('/') >= 0) {
+            let parts = this.bugId.split('/');
+            return parts[1];
+        } else {
+            return this.bugId;
+        }
+    }
+
+    onSystemChange(event: Event) {
+        let select = event.target as Select;
+
+        // If no actual value is selected, do not register a change, as
+        // doing so would wipe the existing system that was set.
+        // We want to retain the existing selected value until options load
+        // and an actual selection is made.
+        if (!select.value) {
+            return;
+        }
+        if (select.value == 'monorail') {
+            this.setSystemMonorail();
+        } else {
+            // TODO: support buganizer.
+            throw new Error('unknown bug system: ' + select.value)
+        }
+    }
+
+    onSelectClosed(e: Event) {
+        // Stop closure of mwc-select closing an mwc-dialog that this bug
+        // picker may be enclosed inside.
+        // https://github.com/material-components/material-web/issues/1150.
+        e.stopPropagation();
+    }
+
+    setSystemMonorail() {
+        if (!this.projectConfig) {
+            throw new Error('invariant violated: projectConfig must be loaded before setting bug system');
+        }
+        this.bugSystem = 'monorail';
+        this.bugId = `${this.projectConfig!.monorail.project}/${this.bugNumber()}`;
+    }
+
+    onNumberChange(event: Event) {
+        let textfield = event.target as TextField;
+
+        // Update the bug number, preserving whatever monorail system has
+        // been set (if any). Do not check the value of the bug system
+        // dropdown as the projectConfig may not have loaded.
+        let monorailSystem = this.monorailSystem();
+        if (monorailSystem != null) {
+            this.bugId = `${monorailSystem}/${textfield.value}`;
+        } else {
+            this.bugId = textfield.value;
+        }
+    }
+
+    static styles = [css`
+        :host {
+            display: inline-block;
+        }
+    `];
+}
diff --git a/analysis/frontend/ui/src/shared_elements/failure_table.ts b/analysis/frontend/ui/src/shared_elements/failure_table.ts
new file mode 100644
index 0000000..49b8281
--- /dev/null
+++ b/analysis/frontend/ui/src/shared_elements/failure_table.ts
@@ -0,0 +1,314 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+/* eslint-disable @typescript-eslint/indent */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import '@material/mwc-button';
+import '@material/mwc-icon';
+import '@material/mwc-list/mwc-list-item';
+import '@material/mwc-select';
+
+import {
+  css,
+  customElement,
+  html,
+  LitElement,
+  property,
+  state,
+  TemplateResult,
+} from 'lit-element';
+import { styleMap } from 'lit-html/directives/style-map';
+import { DateTime } from 'luxon';
+
+import {
+  DistinctClusterFailure,
+  getClustersService,
+  QueryClusterFailuresResponse,
+} from '../services/cluster';
+import {
+  countAndSortFailures,
+  defaultFailureFilter,
+  defaultImpactFilter,
+  FailureFilter,
+  FailureFilters,
+  FailureGroup,
+  VariantGroup,
+  groupAndCountFailures,
+  ImpactFilter,
+  ImpactFilters,
+  MetricName,
+  sortFailureGroups,
+  countDistictVariantValues,
+} from '../tools/failures_tools';
+import {
+  clLink,
+  clName,
+  failureLink,
+} from '../tools/urlHandling/links';
+
+// Indent of each level of grouping in the table in pixels.
+const levelIndent = 10;
+
+// FailureTable lists the failures in a cluster tracked by Weetbix.
+@customElement('failure-table')
+export class FailureTable extends LitElement {
+  @property()
+    project = '';
+
+  @property()
+    clusterAlgorithm = '';
+
+  @property()
+    clusterID = '';
+
+  @state()
+    failures: DistinctClusterFailure[] | undefined;
+
+  @state()
+    groups: FailureGroup[] = [];
+
+  @state()
+    variants: VariantGroup[] = [];
+
+  @state()
+    failureFilter: FailureFilter = defaultFailureFilter;
+
+  @state()
+    impactFilter: ImpactFilter = defaultImpactFilter;
+
+  @property()
+    sortMetric: MetricName = 'latestFailureTime';
+
+  @property({ type: Boolean })
+    ascending = false;
+
+  connectedCallback() {
+    super.connectedCallback();
+
+    const service = getClustersService();
+    service.queryClusterFailures({
+      parent: `projects/${this.project}/clusters/${this.clusterAlgorithm}/${this.clusterID}/failures`,
+    }).then((response: QueryClusterFailuresResponse) => {
+      this.failures = response.failures;
+      this.variants = countDistictVariantValues(response.failures || []);
+      this.groupCountAndSortFailures();
+    });
+  }
+
+  groupCountAndSortFailures() {
+    if (this.failures) {
+      this.groups = groupAndCountFailures(this.failures, this.variants, this.failureFilter);
+    }
+    this.groups = countAndSortFailures(this.groups, this.impactFilter);
+    this.sortFailures();
+  }
+
+  sortFailures() {
+    this.groups = sortFailureGroups(this.groups, this.sortMetric, this.ascending);
+    this.requestUpdate();
+  }
+
+  toggleSort(metric: MetricName) {
+    if (metric === this.sortMetric) {
+      this.ascending = !this.ascending;
+    } else {
+      this.sortMetric = metric;
+      this.ascending = false;
+    }
+    this.sortFailures();
+  }
+
+  onImpactFilterChanged() {
+    const item = this.shadowRoot!.querySelector('#impact-filter [selected]');
+    if (item) {
+      const selected = item.getAttribute('value');
+      this.impactFilter = ImpactFilters.filter((filter) => filter.name == selected)?.[0] || ImpactFilters[1];
+    }
+    this.groups = countAndSortFailures(this.groups, this.impactFilter);
+  }
+
+  onFailureFilterChanged() {
+    const item = this.shadowRoot!.querySelector('#failure-filter [selected]');
+    if (item) {
+      this.failureFilter = (item.getAttribute('value') as FailureFilter) || FailureFilters[0];
+    }
+    this.groupCountAndSortFailures();
+  }
+
+  toggleVariant(variant: VariantGroup) {
+    const index = this.variants.indexOf(variant);
+    this.variants.splice(index, 1);
+    variant.isSelected = !variant.isSelected;
+    const numSelected = this.variants.filter((v) => v.isSelected).length;
+    this.variants.splice(numSelected, 0, variant);
+    this.groupCountAndSortFailures();
+  }
+
+  toggleExpand(group: FailureGroup) {
+    group.isExpanded = !group.isExpanded;
+    this.requestUpdate();
+  }
+
+  render() {
+    const unselectedVariants = this.variants.filter((v) => !v.isSelected).map((v) => v.key);
+    if (this.failures === undefined) {
+      return html`Loading cluster failures...`;
+    }
+    const ungroupedVariants = (failure: DistinctClusterFailure) => {
+      return unselectedVariants.map((key) => failure.variant?.def[key] !== undefined ? { key: key, value: failure.variant.def[key] } : null).filter((v) => v);
+    };
+    const indentStyle = (level: number) => {
+      return styleMap({ paddingLeft: (levelIndent * level) + 'px' });
+    };
+    const groupRow = (group: FailureGroup): TemplateResult => {
+      return html`
+            <tr>
+                ${group.failure ?
+        html`<td style=${indentStyle(group.level)}>
+                        <a href=${failureLink(group.failure)} target="_blank">${group.failure.ingestedInvocationId}</a>
+                        ${(group.failure.changelists !== undefined && group.failure.changelists.length > 0) ?
+                            html`(<a href=${clLink(group.failure.changelists[0])}>${clName(group.failure.changelists[0])}</a>)` : html``}
+                        <span class="variant-info">${ungroupedVariants(group.failure).map((v) => v && `${v.key}: ${v.value}`).filter((v) => v).join(', ')}</span>
+                    </td>` :
+        html`<td class="group" style=${indentStyle(group.level)} @click=${() => this.toggleExpand(group)}>
+                        <mwc-icon>${group.isExpanded ? 'keyboard_arrow_down' : 'keyboard_arrow_right'}</mwc-icon>
+                        ${group.key.value || 'none'} ${group.key.type == 'test' ? html`- <a href="https://ci.chromium.org/ui/test/${this.project}/${group.key.value}" target="_blank">history</a>` : null}
+                    </td>`}
+                <td class="number">
+                    ${group.failure ?
+        (group.failure.presubmitRun ?
+            html`<a class="presubmit-link" href="https://luci-change-verifier.appspot.com/ui/run/${group.failure.presubmitRun.presubmitRunId.id}" target="_blank">${group.presubmitRejects}</a>` :
+            '-') : group.presubmitRejects}
+                </td>
+                <td class="number">${group.invocationFailures}</td>
+                <td class="number">${group.criticalFailuresExonerated}</td>
+                <td class="number">${group.failures}</td>
+                <td>${DateTime.fromISO(group.latestFailureTime).toRelative()}</td>
+            </tr>
+            ${group.isExpanded ? group.children.map((child) => groupRow(child)) : null}`;
+    };
+    const groupByButton = (variant: VariantGroup) => {
+      return html`
+                <mwc-button
+                    label=${`${variant.key} (${variant.values.length})`}
+                    ?unelevated=${variant.isSelected}
+                    ?outlined=${!variant.isSelected}
+                    @click=${() => this.toggleVariant(variant)}></mwc-button>`;
+    };
+    return html`
+            <div class="controls">
+                <div class="select-offset">
+                    <mwc-select id="failure-filter" outlined label="Failure Type" @change=${() => this.onFailureFilterChanged()}>
+                        ${FailureFilters.map((filter) => html`<mwc-list-item ?selected=${filter == this.failureFilter} value="${filter}">${filter}</mwc-list-item>`)}
+                    </mwc-select>
+                </div>
+                <div class="select-offset">
+                    <mwc-select id="impact-filter" outlined label="Impact" @change=${() => this.onImpactFilterChanged()}>
+                        ${ImpactFilters.map((filter) => html`<mwc-list-item ?selected=${filter == this.impactFilter} value="${filter.name}">${filter.name}</mwc-list-item>`)}
+                    </mwc-select>
+                </div>
+                <div>
+                    <div class="label">
+                        Group By
+                    </div>
+                    ${this.variants.map((v) => groupByButton(v))}
+                </div>
+            </div>
+            <table data-testid="failures-table">
+                <thead>
+                    <tr>
+                        <th></th>
+                        <th class="sortable" @click=${() => this.toggleSort('presubmitRejects')}>
+                            User Cls Failed Presubmit
+                            ${this.sortMetric === 'presubmitRejects' ? html`<mwc-icon>${this.ascending ? 'expand_less' : 'expand_more'}</mwc-icon>` : null}
+                        </th>
+                        <th class="sortable" @click=${() => this.toggleSort('invocationFailures')}>
+                            Builds Failed
+                            ${this.sortMetric === 'invocationFailures' ? html`<mwc-icon>${this.ascending ? 'expand_less' : 'expand_more'}</mwc-icon>` : null}
+                        </th>
+                        <th class="sortable" @click=${() => this.toggleSort('criticalFailuresExonerated')}>
+                            Presubmit-Blocking Failures Exonerated
+                            ${this.sortMetric === 'criticalFailuresExonerated' ? html`<mwc-icon>${this.ascending ? 'expand_less' : 'expand_more'}</mwc-icon>` : null}
+                        </th>
+                        <th class="sortable" @click=${() => this.toggleSort('failures')}>
+                            Total Failures
+                            ${this.sortMetric === 'failures' ? html`<mwc-icon>${this.ascending ? 'expand_less' : 'expand_more'}</mwc-icon>` : null}
+                        </th>
+                        <th class="sortable" @click=${() => this.toggleSort('latestFailureTime')}>
+                            Latest Failure Time
+                            ${this.sortMetric === 'latestFailureTime' ? html`<mwc-icon>${this.ascending ? 'expand_less' : 'expand_more'}</mwc-icon>` : null}
+                        </th>
+                    </tr>
+                </thead>
+                <tbody>
+                    ${this.groups.map((group) => groupRow(group))}
+                </tbody>
+            </table>
+        `;
+  }
+  static styles = [css`
+        .controls {
+            display: flex;
+            gap: 30px;
+        }
+        .label {
+            color: var(--greyed-out-text-color);
+            font-size: var(--font-size-small);
+        }
+        .select-offset {
+            padding-top: 7px
+        }
+        #impact-filter {
+            width: 280px;
+        }
+        table {
+            border-collapse: collapse;
+            width: 100%;
+            table-layout: fixed;
+        }
+        th {
+            font-weight: normal;
+            color: var(--greyed-out-text-color);
+            font-size: var(--font-size-small);
+            text-align: left;
+        }
+        td,th {
+            padding: 4px;
+            max-width: 80%;
+        }
+        td.number {
+            text-align: right;
+        }
+        td.group {
+            word-break: break-all;
+        }
+        th.sortable {
+            cursor: pointer;
+            width:120px;
+        }
+        tbody tr:hover {
+            background-color: var(--light-active-color);
+        }
+        .group {
+            cursor: pointer;
+            --mdc-icon-size: var(--font-size-default);
+        }
+        .variant-info {
+            color: var(--greyed-out-text-color);
+            font-size: var(--font-size-small);
+        }
+        .presubmit-link {
+            font-size: var(--font-size-small);
+        }
+ `];
+}
diff --git a/analysis/frontend/ui/src/testing_tools/functions.ts b/analysis/frontend/ui/src/testing_tools/functions.ts
new file mode 100644
index 0000000..01f8d3b
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/functions.ts
@@ -0,0 +1,19 @@
+/* eslint-disable @typescript-eslint/no-empty-function */
+/* eslint-disable @typescript-eslint/no-unused-vars */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export const identityFunction = () => {};
+
+export const noopStateChanger = (_: unknown) => {};
diff --git a/analysis/frontend/ui/src/testing_tools/generators.ts b/analysis/frontend/ui/src/testing_tools/generators.ts
new file mode 100644
index 0000000..15d0538
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/generators.ts
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export const generateRandomNumber = (max: number) => {
+  return Math.trunc(Math.random() * max + 1);
+};
diff --git a/analysis/frontend/ui/src/testing_tools/libs/mock_router.tsx b/analysis/frontend/ui/src/testing_tools/libs/mock_router.tsx
new file mode 100644
index 0000000..1d9a7c7
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/libs/mock_router.tsx
@@ -0,0 +1,88 @@
+/* eslint-disable valid-jsdoc */
+/* eslint-disable @typescript-eslint/no-explicit-any */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { FC } from 'react';
+import {
+  QueryClient,
+  QueryClientProvider,
+} from 'react-query';
+import {
+  BrowserRouter as Router,
+  Route,
+  Routes,
+} from 'react-router-dom';
+
+import {
+  render,
+  RenderResult,
+} from '@testing-library/react';
+
+/**
+ * Renders a component wrapped with a mock Router.
+ *
+ * @param ui The component to render.
+ * @param route The route that the current component is at, defaults to '/'.
+ * @return The render result.
+ */
+export const renderWithRouter = (
+
+    ui: React.ReactElement<any, string | React.JSXElementConstructor<any>>,
+    route = '/',
+): RenderResult => {
+  window.history.pushState({}, 'Test page', route);
+
+  return render(ui, { wrapper: Router });
+};
+
+/**
+ * Renders a component with a mock router and a mock query client.
+ *
+ * @param ui The UI component to render.
+ * @param route The route that the current component is at, defaults to '/'.
+ * @param routeDefinition The definition of the current route,
+ *                        useful for getting route params.
+ * @return The render result.
+ */
+export const renderWithRouterAndClient = (
+    ui: React.ReactElement,
+    route = '/',
+    routeDefinition = '',
+) => {
+  const wrapper: FC = ({ children }) => {
+    return (
+      <Router >
+        <Routes>
+          <Route
+            path={routeDefinition ? routeDefinition : route}
+            element={children}/>
+        </Routes>
+      </Router>
+    );
+  };
+  window.history.pushState({}, 'Test page', route);
+  const client = new QueryClient({
+    defaultOptions: {
+      queries: {
+        retry: false,
+      },
+    },
+  });
+  return render(
+      <QueryClientProvider client={client}>{ui}</QueryClientProvider>,
+      {
+        wrapper,
+      });
+};
diff --git a/analysis/frontend/ui/src/testing_tools/libs/mock_rquery.tsx b/analysis/frontend/ui/src/testing_tools/libs/mock_rquery.tsx
new file mode 100644
index 0000000..bb3f61f
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/libs/mock_rquery.tsx
@@ -0,0 +1,28 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  QueryClient,
+  QueryClientProvider,
+} from 'react-query';
+
+import { render } from '@testing-library/react';
+
+export const renderWithClient = (ui: React.ReactElement) => {
+  const client = new QueryClient();
+
+  return render(
+      <QueryClientProvider client={client}>{ui}</QueryClientProvider>,
+  );
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/authstate_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/authstate_mock.ts
new file mode 100644
index 0000000..2dc36b1
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/authstate_mock.ts
@@ -0,0 +1,31 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import fetchMock from 'fetch-mock-jest';
+
+export const createMockAuthState = () => {
+  return {
+    'identity': 'user:user@example.com',
+    'email': 'user@example.com',
+    'picture': '',
+    'accessToken': 'token_text_access',
+    'accessTokenExpiry': 1648105586,
+    'idToken': 'token_text',
+    'idTokenExpiry': 1648105586,
+  };
+};
+
+export const mockFetchAuthState = () => {
+  fetchMock.get('/api/authState', createMockAuthState());
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/bug_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/bug_mock.ts
new file mode 100644
index 0000000..fb62227
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/bug_mock.ts
@@ -0,0 +1,30 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+
+import { Issue } from '../../services/monorail';
+
+export const createMockBug = (): Issue => {
+  return {
+    name: 'bug for rule',
+    summary: 'a bug for a rule',
+    status: {
+      status: 'accepted',
+      derivation: '',
+    },
+    reporter: 'user@example.com',
+    modifyTime: dayjs().toISOString(),
+  };
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/cluster_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/cluster_mock.ts
new file mode 100644
index 0000000..671ec28
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/cluster_mock.ts
@@ -0,0 +1,112 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import fetchMock from 'fetch-mock-jest';
+
+import {
+  Cluster,
+  ClusterSummary,
+  QueryClusterSummariesRequest,
+  QueryClusterSummariesResponse,
+  QueryClusterFailuresRequest,
+  QueryClusterFailuresResponse,
+  DistinctClusterFailure,
+} from '../../services/cluster';
+
+export const getMockCluster = (id: string): Cluster => {
+  return {
+    'name': `projects/testproject/clusters/rules-v2/${id}`,
+    'hasExample': true,
+    'title': '',
+    'userClsFailedPresubmit': {
+      'oneDay': { 'nominal': '98' },
+      'threeDay': { 'nominal': '158' },
+      'sevenDay': { 'nominal': '167' },
+    },
+    'criticalFailuresExonerated': {
+      'oneDay': { 'nominal': '5625' },
+      'threeDay': { 'nominal': '14052' },
+      'sevenDay': { 'nominal': '13800' },
+    },
+    'failures': {
+      'oneDay': { 'nominal': '7625' },
+      'threeDay': { 'nominal': '16052' },
+      'sevenDay': { 'nominal': '15800' },
+    },
+    'equivalentFailureAssociationRule': '',
+  };
+};
+
+export const getMockRuleClusterSummary = (id: string): ClusterSummary => {
+  return {
+    'clusterId': {
+      'algorithm': 'rules-v2',
+      'id': id,
+    },
+    'title': 'reason LIKE "blah%"',
+    'bug': {
+      'system': 'buganizer',
+      'id': '123456789',
+      'linkText': 'b/123456789',
+      'url': 'https://buganizer/123456789',
+    },
+    'presubmitRejects': '27',
+    'criticalFailuresExonerated': '918',
+    'failures': '1871',
+  };
+};
+
+export const getMockSuggestedClusterSummary = (id: string): ClusterSummary => {
+  return {
+    'clusterId': {
+      'algorithm': 'reason-v3',
+      'id': id,
+    },
+    'bug': undefined,
+    'title': 'reason LIKE "blah%"',
+    'presubmitRejects': '29',
+    'criticalFailuresExonerated': '919',
+    'failures': '1872',
+  };
+};
+
+export const mockQueryClusterSummaries = (request: QueryClusterSummariesRequest, response: QueryClusterSummariesResponse) => {
+  fetchMock.post({
+    url: 'http://localhost/prpc/weetbix.v1.Clusters/QueryClusterSummaries',
+    body: request,
+  }, {
+    headers: {
+      'X-Prpc-Grpc-Code': '0',
+    },
+    body: ')]}\'' + JSON.stringify(response),
+  }, { overwriteRoutes: true });
+};
+
+export const mockQueryClusterFailures = (parent: string, failures: DistinctClusterFailure[] | undefined) => {
+  const request: QueryClusterFailuresRequest = {
+    parent: parent,
+  };
+  const response: QueryClusterFailuresResponse = {
+    failures: failures,
+  };
+  fetchMock.post({
+    url: 'http://localhost/prpc/weetbix.v1.Clusters/QueryClusterFailures',
+    body: request,
+  }, {
+    headers: {
+      'X-Prpc-Grpc-Code': '0',
+    },
+    body: ')]}\'' + JSON.stringify(response),
+  }, { overwriteRoutes: true });
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/failures_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/failures_mock.ts
new file mode 100644
index 0000000..9853bd9
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/failures_mock.ts
@@ -0,0 +1,205 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import { DistinctClusterFailure } from '../../services/cluster';
+
+import {
+  FailureGroup,
+  GroupKey,
+  VariantGroup,
+  ImpactFilter,
+  ImpactFilters,
+} from '../../tools/failures_tools';
+
+class ClusterFailureBuilder {
+  failure: DistinctClusterFailure;
+  constructor() {
+    this.failure = {
+      testId: 'ninja://dir/test.param',
+      variant: undefined,
+      presubmitRun: {
+        presubmitRunId: { system: 'cv', id: 'presubmitRunId' },
+        owner: 'user',
+        mode: 'FULL_RUN',
+      },
+      changelists: [{
+        host: 'clproject-review.googlesource.com',
+        change: '123456',
+        patchset: 7,
+      }],
+      partitionTime: '2021-05-12T19:05:34',
+      exonerations: undefined,
+      buildStatus: 'BUILD_STATUS_SUCCESS',
+      isBuildCritical: true,
+      ingestedInvocationId: 'ingestedInvocationId',
+      isIngestedInvocationBlocked: false,
+      count: 1,
+    };
+  }
+  build(): DistinctClusterFailure {
+    return this.failure;
+  }
+  ingestedInvocationBlocked() {
+    this.failure.isIngestedInvocationBlocked = true;
+    return this;
+  }
+  notPresubmitCritical() {
+    this.failure.isBuildCritical = false;
+    return this;
+  }
+  buildFailed() {
+    this.failure.buildStatus = 'BUILD_STATUS_FAILURE';
+    return this;
+  }
+  dryRun() {
+    this.failure.presubmitRun = {
+      presubmitRunId: { system: 'cv', id: 'presubmitRunId' },
+      owner: 'user',
+      mode: 'DRY_RUN',
+    };
+    return this;
+  }
+  exonerateOccursOnOtherCLs() {
+    this.failure.exonerations = [];
+    this.failure.exonerations.push({ reason: 'OCCURS_ON_OTHER_CLS' });
+    return this;
+  }
+  exonerateNotCritical() {
+    this.failure.exonerations = [];
+    this.failure.exonerations.push({ reason: 'NOT_CRITICAL' });
+    return this;
+  }
+  withVariantGroups(key: string, value: string) {
+    if (this.failure.variant === undefined) {
+      this.failure.variant = { def: {} };
+    }
+    this.failure.variant.def[key] = value;
+    return this;
+  }
+  withTestId(id: string) {
+    this.failure.testId = id;
+    return this;
+  }
+  withoutPresubmit() {
+    this.failure.changelists = undefined;
+    this.failure.presubmitRun = undefined;
+    return this;
+  }
+}
+
+export const newMockGroup = (key: GroupKey): FailureGroupBuilder => {
+  return new FailureGroupBuilder(key);
+};
+
+class FailureGroupBuilder {
+  failureGroup: FailureGroup;
+  constructor(key: GroupKey) {
+    this.failureGroup = {
+      id: key.value,
+      key,
+      children: [],
+      criticalFailuresExonerated: 0,
+      failures: 0,
+      invocationFailures: 0,
+      presubmitRejects: 0,
+      latestFailureTime: dayjs().toISOString(),
+      isExpanded: false,
+      level: 0,
+      failure: undefined,
+    };
+  }
+
+  build(): FailureGroup {
+    return this.failureGroup;
+  }
+
+  withFailures(failures: number) {
+    this.failureGroup.failures = failures;
+    return this;
+  }
+
+  withPresubmitRejects(presubmitRejects: number) {
+    this.failureGroup.presubmitRejects = presubmitRejects;
+    return this;
+  }
+
+  withCriticalFailuresExonerated(criticalFailuresExonerated: number) {
+    this.failureGroup.criticalFailuresExonerated = criticalFailuresExonerated;
+    return this;
+  }
+
+  withInvocationFailures(invocationFailures: number) {
+    this.failureGroup.invocationFailures =invocationFailures;
+    return this;
+  }
+
+  withFailure(failure: DistinctClusterFailure) {
+    this.failureGroup.failure = failure;
+    return this;
+  }
+
+  withChildren(children: FailureGroup[]) {
+    this.failureGroup.children = children;
+    return this;
+  }
+}
+
+// Helper functions.
+export const impactFilterNamed = (name: string) => {
+  return ImpactFilters.filter((f: ImpactFilter) => name == f.name)?.[0];
+};
+
+export const newMockFailure = (): ClusterFailureBuilder => {
+  return new ClusterFailureBuilder();
+};
+
+export const createDefaultMockFailure = (): DistinctClusterFailure => {
+  return newMockFailure().build();
+};
+
+export const createDefaultMockFailures = (num = 5): Array<DistinctClusterFailure> => {
+  return Array.from(Array(num).keys())
+      .map(() => createDefaultMockFailure());
+};
+
+
+export const createMockVariantGroups = (): VariantGroup[] => {
+  return Array.from(Array(4).keys())
+      .map((k) =>(
+        {
+          key: `v${k}`,
+          values: [
+            `value${k}`,
+          ],
+          isSelected: false,
+        }
+      ));
+};
+
+export const createDefaultMockFailureGroup = (key: GroupKey | null = null): FailureGroup => {
+  if (!key) {
+    key = { type: 'test', value: 'testgroup' };
+  }
+  return newMockGroup(key).withFailures(1).build();
+};
+
+export const createDefaultMockFailureGroupWithChildren = (): FailureGroup => {
+  return newMockGroup({ type: 'test', value: 'testgroup' })
+      .withChildren([
+        newMockGroup({ type: 'leaf', value: 'a3' }).withFailures(3).build(),
+        newMockGroup({ type: 'leaf', value: 'a2' }).withFailures(2).build(),
+        newMockGroup({ type: 'leaf', value: 'a1' }).withFailures(1).build(),
+      ]).build();
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/progress_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/progress_mock.ts
new file mode 100644
index 0000000..d85e0da
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/progress_mock.ts
@@ -0,0 +1,50 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+
+import { ReclusteringProgress } from '../../services/cluster';
+
+export const createMockProgress = (progress: number): ReclusteringProgress => {
+  return {
+    progressPerMille: progress,
+    next: {
+      rulesVersion: dayjs().toISOString(),
+      configVersion: dayjs().toISOString(),
+      algorithmsVersion: 7,
+    },
+    last: {
+      rulesVersion: dayjs().subtract(2, 'minutes').toISOString(),
+      configVersion: dayjs().subtract(2, 'minutes').toISOString(),
+      algorithmsVersion: 6,
+    },
+  };
+};
+
+export const createMockDoneProgress = (): ReclusteringProgress => {
+  const currentDate = dayjs();
+  return {
+    progressPerMille: 1000,
+    next: {
+      rulesVersion: currentDate.toISOString(),
+      configVersion: currentDate.toISOString(),
+      algorithmsVersion: 7,
+    },
+    last: {
+      rulesVersion: currentDate.toISOString(),
+      configVersion: currentDate.toISOString(),
+      algorithmsVersion: 7,
+    },
+  };
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/projects_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/projects_mock.ts
new file mode 100644
index 0000000..12c6fe1
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/projects_mock.ts
@@ -0,0 +1,36 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import fetchMock from 'fetch-mock-jest';
+
+import { ProjectConfig } from '../../services/project';
+
+export const createMockProjectConfig = (): ProjectConfig => {
+  return {
+    name: 'projects/chromium/config',
+    monorail: {
+      project: 'chromium',
+      displayPrefix: 'crbug.com',
+    },
+  };
+};
+
+export const mockFetchProjectConfig = () => {
+  fetchMock.post('http://localhost/prpc/weetbix.v1.Projects/GetConfig', {
+    headers: {
+      'X-Prpc-Grpc-Code': '0',
+    },
+    body: ')]}\'' + JSON.stringify(createMockProjectConfig()),
+  });
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/rule_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/rule_mock.ts
new file mode 100644
index 0000000..3b20f78
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/rule_mock.ts
@@ -0,0 +1,54 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import fetchMock from 'fetch-mock-jest';
+
+import { Rule } from '../../services/rules';
+
+export const createDefaultMockRule = (): Rule => {
+  return {
+    name: 'projects/chromium/rules/ce83f8395178a0f2edad59fc1a167818',
+    project: 'chromium',
+    ruleId: 'ce83f8395178a0f2edad59fc1a167818',
+    ruleDefinition: 'test = "blink_lint_expectations"',
+    bug: {
+      system: 'monorail',
+      id: 'chromium/920702',
+      linkText: 'crbug.com/920702',
+      url: 'https://monorail-staging.appspot.com/p/chromium/issues/detail?id=920702',
+    },
+    isActive: true,
+    isManagingBug: true,
+    sourceCluster: {
+      algorithm: 'testname-v3',
+      id: '78ff0812026b30570ca730b1541125ea',
+    },
+    createTime: dayjs().toISOString(),
+    createUser: 'weetbix',
+    lastUpdateTime: dayjs().toISOString(),
+    lastUpdateUser: 'user@example.com',
+    predicateLastUpdateTime: '2022-01-31T03:36:14.896430Z',
+    etag: 'W/"2022-01-31T03:36:14.89643Z"',
+  };
+};
+
+export const mockFetchRule = () => {
+  fetchMock.post('http://localhost/prpc/weetbix.v1.Rules/Get', {
+    headers: {
+      'X-Prpc-Grpc-Code': '0',
+    },
+    body: ')]}\'' + JSON.stringify(createDefaultMockRule()),
+  });
+};
diff --git a/analysis/frontend/ui/src/testing_tools/mocks/rules_mock.ts b/analysis/frontend/ui/src/testing_tools/mocks/rules_mock.ts
new file mode 100644
index 0000000..80693da
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/mocks/rules_mock.ts
@@ -0,0 +1,53 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import fetchMock from 'fetch-mock-jest';
+
+import { ListRulesResponse } from '../../services/rules';
+import { createDefaultMockRule } from './rule_mock';
+
+export const createDefaultMockListRulesResponse = (): ListRulesResponse => {
+  const rule1 = createDefaultMockRule();
+  rule1.name = 'projects/chromium/rules/ce83f8395178a0f2edad59fc1a160001';
+  rule1.ruleId = 'ce83f8395178a0f2edad59fc1a160001';
+  rule1.bug = {
+    system: 'monorail',
+    id: 'chromium/90001',
+    linkText: 'crbug.com/90001',
+    url: 'https://monorail-staging.appspot.com/p/chromium/issues/detail?id=90001',
+  };
+  rule1.ruleDefinition = 'test LIKE "rule1%"';
+  const rule2 = createDefaultMockRule();
+  rule2.name = 'projects/chromium/rules/ce83f8395178a0f2edad59fc1a160002';
+  rule2.ruleId = 'ce83f8395178a0f2edad59fc1a160002';
+  rule2.bug = {
+    system: 'monorail',
+    id: 'chromium/90002',
+    linkText: 'crbug.com/90002',
+    url: 'https://monorail-staging.appspot.com/p/chromium/issues/detail?id=90002',
+  };
+  rule2.ruleDefinition = 'reason LIKE "rule2%"';
+  return {
+    rules: [rule1, rule2],
+  };
+};
+
+export const mockFetchRules = () => {
+  fetchMock.post('http://localhost/prpc/weetbix.v1.Rules/List', {
+    headers: {
+      'X-Prpc-Grpc-Code': '0',
+    },
+    body: ')]}\'' + JSON.stringify(createDefaultMockListRulesResponse()),
+  });
+};
diff --git a/analysis/frontend/ui/src/testing_tools/setUpEnv.ts b/analysis/frontend/ui/src/testing_tools/setUpEnv.ts
new file mode 100644
index 0000000..0b04661
--- /dev/null
+++ b/analysis/frontend/ui/src/testing_tools/setUpEnv.ts
@@ -0,0 +1,42 @@
+/* eslint-disable @typescript-eslint/ban-ts-comment */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  TextDecoder,
+  TextEncoder,
+} from 'util';
+
+import dayjs from 'dayjs';
+import localizedFormat from 'dayjs/plugin/localizedFormat';
+import relativeTime from 'dayjs/plugin/relativeTime';
+import UTC from 'dayjs/plugin/utc';
+import fetch from 'node-fetch';
+
+/**
+ * jsdom doesn't have those by default, we need to add them for fetch testing.
+ */
+global.TextEncoder = TextEncoder;
+
+// @ts-ignore
+global.TextDecoder = TextDecoder;
+
+// @ts-ignore
+global.fetch = fetch;
+
+window.monorailHostname = 'crbug.com';
+
+dayjs.extend(relativeTime);
+dayjs.extend(UTC);
+dayjs.extend(localizedFormat);
diff --git a/analysis/frontend/ui/src/tools/failure_tools.test.ts b/analysis/frontend/ui/src/tools/failure_tools.test.ts
new file mode 100644
index 0000000..68c8c5d
--- /dev/null
+++ b/analysis/frontend/ui/src/tools/failure_tools.test.ts
@@ -0,0 +1,373 @@
+/* eslint-disable jest/no-conditional-expect */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+import { DistinctClusterFailure } from '../services/cluster';
+import { impactFilterNamed, newMockFailure, newMockGroup } from '../testing_tools/mocks/failures_mock';
+import {
+  FailureGroup,
+  groupFailures,
+  rejectedIngestedInvocationIdsExtractor,
+  rejectedPresubmitRunIdsExtractor,
+  sortFailureGroups,
+  treeDistinctValues,
+} from './failures_tools';
+
+interface ExtractorTestCase {
+    failure: DistinctClusterFailure;
+    filter: string;
+    shouldExtractIngestedInvocationId: boolean;
+    shouldExtractPresubmitRunId: boolean;
+}
+
+describe.each<ExtractorTestCase>([{
+  failure: newMockFailure().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateNotCritical().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateOccursOnOtherCLs().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateNotCritical().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().buildFailed().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().buildFailed().notPresubmitCritical().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().buildFailed().dryRun().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().buildFailed().exonerateOccursOnOtherCLs().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().buildFailed().exonerateNotCritical().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateOccursOnOtherCLs().build(),
+  filter: 'Actual Impact',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().notPresubmitCritical().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().dryRun().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateOccursOnOtherCLs().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().notPresubmitCritical().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateNotCritical().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateNotCritical().build(),
+  filter: 'Without Weetbix Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().exonerateOccursOnOtherCLs().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().notPresubmitCritical().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().dryRun().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().exonerateNotCritical().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: false,
+  shouldExtractPresubmitRunId: false,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateNotCritical().build(),
+  filter: 'Without All Exoneration',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().build(),
+  filter: 'Without Any Retries',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().build(),
+  filter: 'Without Any Retries',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().exonerateOccursOnOtherCLs().build(),
+  filter: 'Without Any Retries',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().build(),
+  filter: 'Without Any Retries',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: true,
+}, {
+  failure: newMockFailure().ingestedInvocationBlocked().exonerateOccursOnOtherCLs().notPresubmitCritical().build(),
+  filter: 'Without Any Retries',
+  shouldExtractIngestedInvocationId: true,
+  shouldExtractPresubmitRunId: false,
+}])('Extractors with %j', (tc: ExtractorTestCase) => {
+  it('should return ids in only the cases expected by failure type and impact filter.', () => {
+    const ingestedInvocationIds = rejectedIngestedInvocationIdsExtractor(impactFilterNamed(tc.filter))(tc.failure);
+    if (tc.shouldExtractIngestedInvocationId) {
+      expect(ingestedInvocationIds.size).toBeGreaterThan(0);
+    } else {
+      expect(ingestedInvocationIds.size).toBe(0);
+    }
+    const presubmitRunIds = rejectedPresubmitRunIdsExtractor(impactFilterNamed(tc.filter))(tc.failure);
+    if (tc.shouldExtractPresubmitRunId) {
+      expect(presubmitRunIds.size).toBeGreaterThan(0);
+    } else {
+      expect(presubmitRunIds.size).toBe(0);
+    }
+  });
+});
+
+describe('groupFailures', () => {
+  it('should put each failure in a separate group when given unique grouping keys', () => {
+    const failures = [
+      newMockFailure().build(),
+      newMockFailure().build(),
+      newMockFailure().build(),
+    ];
+    let unique = 0;
+    const groups: FailureGroup[] = groupFailures(failures, () => [{ type: 'variant', key: 'v1', value: '' + unique++ }]);
+    expect(groups.length).toBe(3);
+    expect(groups[0].children.length).toBe(1);
+  });
+  it('should put each failure in a single group when given a single grouping key', () => {
+    const failures = [
+      newMockFailure().build(),
+      newMockFailure().build(),
+      newMockFailure().build(),
+    ];
+    const groups: FailureGroup[] = groupFailures(failures, () => [{ type: 'variant', key: 'v1', value: 'group1' }]);
+    expect(groups.length).toBe(1);
+    expect(groups[0].children.length).toBe(3);
+  });
+  it('should put group failures into multiple levels', () => {
+    const failures = [
+      newMockFailure().withVariantGroups('v1', 'a').withVariantGroups('v2', 'a').build(),
+      newMockFailure().withVariantGroups('v1', 'a').withVariantGroups('v2', 'b').build(),
+      newMockFailure().withVariantGroups('v1', 'b').withVariantGroups('v2', 'a').build(),
+      newMockFailure().withVariantGroups('v1', 'b').withVariantGroups('v2', 'b').build(),
+    ];
+    const groups: FailureGroup[] = groupFailures(failures, (f) => [
+      { type: 'variant', key: 'v1', value: f.variant?.def['v1'] || '' },
+      { type: 'variant', key: 'v2', value: f.variant?.def['v2'] || '' },
+    ]);
+    expect(groups.length).toBe(2);
+    expect(groups[0].children.length).toBe(2);
+    expect(groups[1].children.length).toBe(2);
+    expect(groups[0].children[0].children.length).toBe(1);
+  });
+});
+
+describe('treeDistinctValues', () => {
+  // A helper to just store the counts to the failures field.
+  const setFailures = (g: FailureGroup, values: Set<string>) => {
+    g.failures = values.size;
+  };
+  it('should have count of 1 for a valid feature', () => {
+    const groups = groupFailures([newMockFailure().build()], () => [{ type: 'variant', key: 'v1', value: 'group' }]);
+
+    treeDistinctValues(groups[0], () => new Set(['a']), setFailures);
+
+    expect(groups[0].failures).toBe(1);
+  });
+  it('should have count of 0 for an invalid feature', () => {
+    const groups = groupFailures([newMockFailure().build()], () => [{ type: 'variant', key: 'v1', value: 'group' }]);
+
+    treeDistinctValues(groups[0], () => new Set(), setFailures);
+
+    expect(groups[0].failures).toBe(0);
+  });
+
+  it('should have count of 1 for two identical features', () => {
+    const groups = groupFailures([
+      newMockFailure().build(),
+      newMockFailure().build(),
+    ], () => [{ type: 'variant', key: 'v1', value: 'group' }]);
+
+    treeDistinctValues(groups[0], () => new Set(['a']), setFailures);
+
+    expect(groups[0].failures).toBe(1);
+  });
+  it('should have count of 2 for two different features', () => {
+    const groups = groupFailures([
+      newMockFailure().withTestId('a').build(),
+      newMockFailure().withTestId('b').build(),
+    ], () => [{ type: 'variant', key: 'v1', value: 'group' }]);
+
+    treeDistinctValues(groups[0], (f) => f.testId ? new Set([f.testId]) : new Set(), setFailures);
+
+    expect(groups[0].failures).toBe(2);
+  });
+  it('should have count of 1 for two identical features in different subgroups', () => {
+    const groups = groupFailures([
+      newMockFailure().withTestId('a').withVariantGroups('group', 'a').build(),
+      newMockFailure().withTestId('a').withVariantGroups('group', 'b').build(),
+    ], (f) => [{ type: 'variant', key: 'v1', value: 'top' }, { type: 'variant', key: 'v1', value: f.variant?.def['group'] || '' }]);
+
+    treeDistinctValues(groups[0], (f) => f.testId ? new Set([f.testId]) : new Set(), setFailures);
+
+    expect(groups[0].failures).toBe(1);
+    expect(groups[0].children[0].failures).toBe(1);
+    expect(groups[0].children[1].failures).toBe(1);
+  });
+  it('should have count of 2 for two different features in different subgroups', () => {
+    const groups = groupFailures([
+      newMockFailure().withTestId('a').withVariantGroups('group', 'a').build(),
+      newMockFailure().withTestId('b').withVariantGroups('group', 'b').build(),
+    ], (f) => [{ type: 'variant', key: 'v1', value: 'top' }, { type: 'variant', key: 'v1', value: f.variant?.def['group'] || '' }]);
+
+    treeDistinctValues(groups[0], (f) => f.testId ? new Set([f.testId]) : new Set(), setFailures);
+
+    expect(groups[0].failures).toBe(2);
+    expect(groups[0].children[0].failures).toBe(1);
+    expect(groups[0].children[1].failures).toBe(1);
+  });
+});
+
+describe('sortFailureGroups', () => {
+  it('sorts top level groups ascending', () => {
+    let groups: FailureGroup[] = [
+      newMockGroup({ type: 'variant', key: 'v1', value: 'c' }).withFailures(3).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'a' }).withFailures(1).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'b' }).withFailures(2).build(),
+    ];
+
+    groups = sortFailureGroups(groups, 'failures', true);
+
+    expect(groups.map((g) => g.key.value)).toEqual(['a', 'b', 'c']);
+  });
+  it('sorts top level groups descending', () => {
+    let groups: FailureGroup[] = [
+      newMockGroup({ type: 'variant', key: 'v1', value: 'c' }).withFailures(3).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'a' }).withFailures(1).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'b' }).withFailures(2).build(),
+    ];
+
+    groups = sortFailureGroups(groups, 'failures', false);
+
+    expect(groups.map((g) => g.key.value)).toEqual(['c', 'b', 'a']);
+  });
+  it('sorts child groups', () => {
+    let groups: FailureGroup[] = [
+      newMockGroup({ type: 'variant', key: 'v1', value: 'c' }).withFailures(3).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'a' }).withFailures(1).withChildren([
+        newMockGroup({ type: 'variant', key: 'v2', value: 'a3' }).withFailures(3).build(),
+        newMockGroup({ type: 'variant', key: 'v2', value: 'a2' }).withFailures(2).build(),
+        newMockGroup({ type: 'variant', key: 'v2', value: 'a1' }).withFailures(1).build(),
+      ]).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'b' }).withFailures(2).build(),
+    ];
+
+    groups = sortFailureGroups(groups, 'failures', true);
+
+    expect(groups.map((g) => g.key.value)).toEqual(['a', 'b', 'c']);
+    expect(groups[0].children.map((g) => g.key.value)).toEqual(['a1', 'a2', 'a3']);
+  });
+  it('sorts on an alternate metric', () => {
+    let groups: FailureGroup[] = [
+      newMockGroup({ type: 'variant', key: 'v1', value: 'c' }).withPresubmitRejects(3).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'a' }).withPresubmitRejects(1).build(),
+      newMockGroup({ type: 'variant', key: 'v1', value: 'b' }).withPresubmitRejects(2).build(),
+    ];
+
+    groups = sortFailureGroups(groups, 'presubmitRejects', true);
+
+    expect(groups.map((g) => g.key.value)).toEqual(['a', 'b', 'c']);
+  });
+});
diff --git a/analysis/frontend/ui/src/tools/failures_tools.ts b/analysis/frontend/ui/src/tools/failures_tools.ts
new file mode 100644
index 0000000..855f6d7
--- /dev/null
+++ b/analysis/frontend/ui/src/tools/failures_tools.ts
@@ -0,0 +1,434 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import { nanoid } from 'nanoid';
+import { DistinctClusterFailure, Exoneration } from '../services/cluster';
+
+/**
+ * Creates a list of distinct variants found in the list of failures provided.
+ *
+ * @param {DistinctClusterFailure[]} failures the failures list.
+ * @return {VariantGroup[]} A list of distinct variants.
+ */
+export const countDistictVariantValues = (failures: DistinctClusterFailure[]): VariantGroup[] => {
+  if (!failures) {
+    return [];
+  }
+  const variantGroups: VariantGroup[] = [];
+  failures.forEach((failure) => {
+    if (failure.variant === undefined) {
+      return;
+    }
+    const def = failure.variant.def;
+    for (const key in def) {
+      if (!Object.prototype.hasOwnProperty.call(def, key)) {
+        continue;
+      }
+      const value = def[key] || '';
+      const variant = variantGroups.filter((e) => e.key === key)?.[0];
+      if (!variant) {
+        variantGroups.push({ key: key, values: [value], isSelected: false });
+      } else {
+        if (variant.values.indexOf(value) === -1) {
+          variant.values.push(value);
+        }
+      }
+    }
+  });
+  return variantGroups;
+};
+
+// group a number of failures into a tree of failure groups.
+// grouper is a function that returns a list of keys, one corresponding to each level of the grouping tree.
+// impactFilter controls how metric counts are aggregated from failures into parent groups (see treeCounts and rejected... functions).
+export const groupFailures = (failures: DistinctClusterFailure[], grouper: (f: DistinctClusterFailure) => GroupKey[]): FailureGroup[] => {
+  const topGroups: FailureGroup[] = [];
+  const leafKey: GroupKey = { type: 'leaf', value: '' };
+  failures.forEach((f) => {
+    const keys = grouper(f);
+    let groups = topGroups;
+    const failureTime = dayjs(f.partitionTime);
+    let level = 0;
+    for (const key of keys) {
+      const group = getOrCreateGroup(
+          groups, key, failureTime.toISOString(),
+      );
+      group.level = level;
+      level += 1;
+      groups = group.children;
+    }
+    const failureGroup = newGroup(leafKey, failureTime.toISOString());
+    failureGroup.failure = f;
+    failureGroup.level = level;
+    groups.push(failureGroup);
+  });
+  return topGroups;
+};
+
+// Create a new group.
+export const newGroup = (key: GroupKey, failureTime: string): FailureGroup => {
+  return {
+    id: key.value || nanoid(),
+    key: key,
+    criticalFailuresExonerated: 0,
+    failures: 0,
+    invocationFailures: 0,
+    presubmitRejects: 0,
+    children: [],
+    isExpanded: false,
+    latestFailureTime: failureTime,
+    level: 0,
+  };
+};
+
+// Find a group by key in the given list of groups, create a new one and insert it if it is not found.
+// failureTime is only used when creating a new group.
+export const getOrCreateGroup = (
+    groups: FailureGroup[], key: GroupKey, failureTime: string,
+): FailureGroup => {
+  let group = groups.filter((g) => keyEqual(g.key, key))?.[0];
+  if (group) {
+    return group;
+  }
+  group = newGroup(key, failureTime);
+  groups.push(group);
+  return group;
+};
+
+// Returns the distinct values returned by featureExtractor for all children of the group.
+// If featureExtractor returns undefined, the failure will be ignored.
+// The distinct values for each group in the tree are also reported to `visitor` as the tree is traversed.
+// A typical `visitor` function will store the count of distinct values in a property of the group.
+export const treeDistinctValues = (
+    group: FailureGroup,
+    featureExtractor: FeatureExtractor,
+    visitor: (group: FailureGroup, distinctValues: Set<string>) => void,
+): Set<string> => {
+  const values: Set<string> = new Set();
+  if (group.failure) {
+    for (const value of featureExtractor(group.failure)) {
+      values.add(value);
+    }
+  } else {
+    for (const child of group.children) {
+      for (const value of treeDistinctValues(
+          child, featureExtractor, visitor,
+      )) {
+        values.add(value);
+      }
+    }
+  }
+  visitor(group, values);
+  return values;
+};
+
+// A FeatureExtractor returns a string representing some feature of a ClusterFailure.
+// Returns undefined if there is no such feature for this failure.
+export type FeatureExtractor = (failure: DistinctClusterFailure) => Set<string>;
+
+// failureIdExtractor returns an extractor that returns a unique failure id for each failure.
+// As failures don't actually have ids, it just returns an incrementing integer.
+export const failureIdsExtractor = (): FeatureExtractor => {
+  let unique = 0;
+  return (f) => {
+    const values: Set<string> = new Set();
+    for (let i = 0; i < f.count; i++) {
+      unique += 1;
+      values.add('' + unique);
+    }
+    return values;
+  };
+};
+
+// criticalFailuresExoneratedIdsExtractor returns an extractor that returns
+// a unique failure id for each failure of a critical test that is exonerated.
+// As failures don't actually have ids, it just returns an incrementing integer.
+export const criticalFailuresExoneratedIdsExtractor = (): FeatureExtractor => {
+  let unique = 0;
+  return (f) => {
+    const values: Set<string> = new Set();
+    if (!f.isBuildCritical) {
+      return values;
+    }
+    let exoneratedByCQ = false;
+    if (f.exonerations != null) {
+      for (let i = 0; i < f.exonerations.length; i++) {
+        // Do not count the exoneration reason NOT_CRITICAL
+        // (as it implies the test is not critical), or the
+        // exoneration reason UNEXPECTED_PASS as the test is considered
+        // passing.
+        if (f.exonerations[i].reason == 'OCCURS_ON_MAINLINE' ||
+              f.exonerations[i].reason == 'OCCURS_ON_OTHER_CLS') {
+          exoneratedByCQ = true;
+        }
+      }
+    }
+    if (!exoneratedByCQ) {
+      return values;
+    }
+
+    for (let i = 0; i < f.count; i++) {
+      unique += 1;
+      values.add('' + unique);
+    }
+    return values;
+  };
+};
+
+// Returns whether the failure was exonerated for a reason other than it occurred
+// on other CLs or on mainline.
+const isExoneratedByNonWeetbix = (exonerations: Exoneration[] | undefined): boolean => {
+  if (exonerations === undefined) {
+    return false;
+  }
+  let hasOtherExoneration = false;
+  for (let i = 0; i < exonerations.length; i++) {
+    if (exonerations[i].reason != 'OCCURS_ON_MAINLINE' &&
+          exonerations[i].reason != 'OCCURS_ON_OTHER_CLS') {
+      hasOtherExoneration = true;
+    }
+  }
+  return hasOtherExoneration;
+};
+
+// Returns an extractor that returns the id of the ingested invocation that was rejected by this failure, if any.
+// The impact filter is taken into account in determining if the invocation was rejected by this failure.
+export const rejectedIngestedInvocationIdsExtractor = (impactFilter: ImpactFilter): FeatureExtractor => {
+  return (failure) => {
+    const values: Set<string> = new Set();
+    // If neither Weetbix nor all exoneration is ignored, we want actual impact.
+    // This requires exclusion of all exonerated test results, as well as
+    // test results from builds which passed (which implies the test results
+    // could not have caused the presubmit run to fail).
+    if (((failure.exonerations !== undefined && failure.exonerations.length > 0) || failure.buildStatus != 'BUILD_STATUS_FAILURE') &&
+                !(impactFilter.ignoreWeetbixExoneration || impactFilter.ignoreAllExoneration)) {
+      return values;
+    }
+    // If not all exoneration is ignored, it means we want actual or without weetbix impact.
+    // All exonerations not made by weetbix should be applied, those made by Weetbix should not
+    // be applied (or will have already been applied).
+    if (isExoneratedByNonWeetbix(failure.exonerations) &&
+        !impactFilter.ignoreAllExoneration) {
+      return values;
+    }
+    if (!failure.isIngestedInvocationBlocked && !impactFilter.ignoreIngestedInvocationBlocked) {
+      return values;
+    }
+    if (failure.ingestedInvocationId) {
+      values.add(failure.ingestedInvocationId);
+    }
+    return values;
+  };
+};
+
+// Returns an extractor that returns the identity of the CL that was rejected by this failure, if any.
+// The impact filter is taken into account in determining if the CL was rejected by this failure.
+export const rejectedPresubmitRunIdsExtractor = (impactFilter: ImpactFilter): FeatureExtractor => {
+  return (failure) => {
+    const values: Set<string> = new Set();
+    // If neither Weetbix nor all exoneration is ignored, we want actual impact.
+    // This requires exclusion of all exonerated test results, as well as
+    // test results from builds which passed (which implies the test results
+    // could not have caused the presubmit run to fail).
+    if (((failure.exonerations !== undefined && failure.exonerations.length > 0) || failure.buildStatus != 'BUILD_STATUS_FAILURE') &&
+                !(impactFilter.ignoreWeetbixExoneration || impactFilter.ignoreAllExoneration)) {
+      return values;
+    }
+    // If not all exoneration is ignored, it means we want actual or without weetbix impact.
+    // All test results exonerated, but not exonerated by weetbix should be ignored.
+    if (isExoneratedByNonWeetbix(failure.exonerations) &&
+        !impactFilter.ignoreAllExoneration) {
+      return values;
+    }
+    if (!failure.isIngestedInvocationBlocked && !impactFilter.ignoreIngestedInvocationBlocked) {
+      return values;
+    }
+    if (failure.changelists !== undefined && failure.changelists.length > 0 &&
+        failure.presubmitRun !== undefined && failure.presubmitRun.owner == 'user' &&
+        failure.isBuildCritical && failure.presubmitRun.mode == 'FULL_RUN') {
+      values.add(failure.changelists[0].host + '/' + failure.changelists[0].change);
+    }
+    return values;
+  };
+};
+
+// Sorts child failure groups at each node of the tree by the given metric.
+export const sortFailureGroups = (
+    groups: FailureGroup[],
+    metric: MetricName,
+    ascending: boolean,
+): FailureGroup[] => {
+  const cloneGroups = [...groups];
+  const getMetric = (group: FailureGroup): number => {
+    switch (metric) {
+      case 'criticalFailuresExonerated':
+        return group.criticalFailuresExonerated;
+      case 'failures':
+        return group.failures;
+      case 'invocationFailures':
+        return group.invocationFailures;
+      case 'presubmitRejects':
+        return group.presubmitRejects;
+      case 'latestFailureTime':
+        return dayjs(group.latestFailureTime).unix();
+      default:
+        throw new Error('unknown metric: ' + metric);
+    }
+  };
+  cloneGroups.sort((a, b) => ascending ? (getMetric(a) - getMetric(b)) : (getMetric(b) - getMetric(a)));
+  for (const group of cloneGroups) {
+    if (group.children.length > 0) {
+      group.children = sortFailureGroups(group.children, metric, ascending);
+    }
+  }
+  return cloneGroups;
+};
+
+/**
+ * Groups failures by the variant groups selected.
+ *
+ * @param {DistinctClusterFailure} failures The list of failures to group.
+ * @param {VariantGroup} variantGroups The list of variant groups to use for grouping.
+ * @param {FailureFilter} failureFilter The failure filter to filter out the failures.
+ * @return {FailureGroup[]} The list of failures grouped by the variants.
+ */
+export const groupAndCountFailures = (
+    failures: DistinctClusterFailure[],
+    variantGroups: VariantGroup[],
+    failureFilter: FailureFilter,
+): FailureGroup[] => {
+  if (failures) {
+    let currentFailures = failures;
+    if (failureFilter == 'Presubmit Failures') {
+      currentFailures = failures.filter((f) => f.presubmitRun);
+    } else if (failureFilter == 'Postsubmit Failures') {
+      currentFailures = failures.filter((f) => !f.presubmitRun);
+    }
+    const groups = groupFailures(currentFailures, (failure) => {
+      const variantValues = variantGroups.filter((v) => v.isSelected)
+          .map((v) => {
+            const key: GroupKey = { type: 'variant', key: v.key, value: failure.variant?.def[v.key] || '' };
+            return key;
+          });
+      return [...variantValues, { type: 'test', value: failure.testId || '' }];
+    });
+    return groups;
+  }
+  return [];
+};
+
+export const countAndSortFailures = (groups: FailureGroup[], impactFilter: ImpactFilter): FailureGroup[] => {
+  const groupsClone = [...groups];
+  groupsClone.forEach((group) => {
+    treeDistinctValues(
+        group, failureIdsExtractor(), (g, values) => g.failures = values.size,
+    );
+    treeDistinctValues(
+        group, criticalFailuresExoneratedIdsExtractor(), (g, values) => g.criticalFailuresExonerated = values.size,
+    );
+    treeDistinctValues(
+        group, rejectedIngestedInvocationIdsExtractor(impactFilter), (g, values) => g.invocationFailures = values.size,
+    );
+    treeDistinctValues(
+        group, rejectedPresubmitRunIdsExtractor(impactFilter), (g, values) => g.presubmitRejects = values.size,
+    );
+  });
+  return groupsClone;
+};
+
+// ImpactFilter represents what kind of impact should be counted or ignored in
+// calculating impact for failures.
+export interface ImpactFilter {
+    name: string;
+    ignoreWeetbixExoneration: boolean;
+    ignoreAllExoneration: boolean;
+    ignoreIngestedInvocationBlocked: boolean;
+}
+export const ImpactFilters: ImpactFilter[] = [
+  {
+    name: 'Actual Impact',
+    ignoreWeetbixExoneration: false,
+    ignoreAllExoneration: false,
+    ignoreIngestedInvocationBlocked: false,
+  }, {
+    name: 'Without Weetbix Exoneration',
+    ignoreWeetbixExoneration: true,
+    ignoreAllExoneration: false,
+    ignoreIngestedInvocationBlocked: false,
+  }, {
+    name: 'Without All Exoneration',
+    ignoreWeetbixExoneration: true,
+    ignoreAllExoneration: true,
+    ignoreIngestedInvocationBlocked: false,
+  }, {
+    name: 'Without Any Retries',
+    ignoreWeetbixExoneration: true,
+    ignoreAllExoneration: true,
+    ignoreIngestedInvocationBlocked: true,
+  },
+];
+
+export const defaultImpactFilter: ImpactFilter = ImpactFilters[0];
+
+// Metrics that can be used for sorting FailureGroups.
+// Each value is a property of FailureGroup.
+export type MetricName = 'presubmitRejects' | 'invocationFailures' | 'criticalFailuresExonerated' | 'failures' | 'latestFailureTime';
+
+export type GroupType = 'test' | 'variant' | 'leaf';
+
+export interface GroupKey {
+  // The type of group.
+  // This could be a group for a test, for a variant value,
+  // or a leaf (for individual failures).
+  type: GroupType;
+
+  // For variant-based grouping keys, the name of the variant.
+  // Unspecified otherwise.
+  key?: string;
+
+  // The name of the group. E.g. the name of the test or the variant value.
+  // May be empty for leaf nodes.
+  value: string;
+}
+
+export const keyEqual = (a: GroupKey, b: GroupKey) => {
+  return a.value === b.value && a.key === b.key && a.type === b.type;
+};
+
+// FailureGroups are nodes in the failure tree hierarchy.
+export interface FailureGroup {
+    id: string;
+    key: GroupKey;
+    criticalFailuresExonerated: number;
+    failures: number;
+    invocationFailures: number;
+    presubmitRejects: number;
+    latestFailureTime: string;
+    level: number;
+    children: FailureGroup[];
+    isExpanded: boolean;
+    failure?: DistinctClusterFailure;
+}
+
+// VariantGroup represents variant key that appear on at least one failure.
+export interface VariantGroup {
+    key: string;
+    values: string[];
+    isSelected: boolean;
+}
+
+export const FailureFilters = ['All Failures', 'Presubmit Failures', 'Postsubmit Failures'] as const;
+export type FailureFilter = typeof FailureFilters[number];
+export const defaultFailureFilter: FailureFilter = FailureFilters[0];
diff --git a/analysis/frontend/ui/src/tools/progress_tools.ts b/analysis/frontend/ui/src/tools/progress_tools.ts
new file mode 100644
index 0000000..f0ea37c
--- /dev/null
+++ b/analysis/frontend/ui/src/tools/progress_tools.ts
@@ -0,0 +1,69 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import dayjs from 'dayjs';
+import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
+import { getClustersService, GetReclusteringProgressRequest, ReclusteringProgress, ClusteringVersion } from '../services/cluster';
+
+dayjs.extend(isSameOrAfter);
+
+export const fetchProgress = async (project: string): Promise<ReclusteringProgress> => {
+  const clustersService = getClustersService();
+  const request: GetReclusteringProgressRequest = {
+    name: `projects/${encodeURIComponent(project)}/reclusteringProgress`,
+  };
+  const response = await clustersService.getReclusteringProgress(request);
+  return response;
+};
+
+export const progressNotYetStarted = -1;
+export const noProgressToShow = -2;
+
+export const progressToLatestAlgorithms = (progress: ReclusteringProgress): number => {
+  return progressTo(progress, (target: ClusteringVersion) => {
+    return target.algorithmsVersion >= progress.next.algorithmsVersion;
+  });
+};
+
+export const progressToLatestConfig = (progress: ReclusteringProgress): number => {
+  const targetConfigVersion = dayjs(progress.next.configVersion);
+  return progressTo(progress, (target: ClusteringVersion) => {
+    return dayjs(target.configVersion).isSameOrAfter(targetConfigVersion);
+  });
+};
+
+export const progressToRulesVersion = (progress: ReclusteringProgress, rulesVersion: string): number => {
+  const ruleDate = dayjs(rulesVersion);
+  return progressTo(progress, (target: ClusteringVersion) => {
+    return dayjs(target.rulesVersion).isSameOrAfter(ruleDate);
+  });
+};
+
+// progressTo returns the progress to completing a re-clustering run
+// satisfying the given re-clustering target, expressed as a predicate.
+// If re-clustering to a goal that would satisfy the target has started,
+// the returned value is value from 0 to 1000. If the run is pending,
+// the value -1 is returned.
+const progressTo = (progress: ReclusteringProgress, predicate: (target: ClusteringVersion) => boolean): number => {
+  if (predicate(progress.last)) {
+    // Completed
+    return 1000;
+  }
+  if (predicate(progress.next)) {
+    return progress.progressPerMille || 0;
+  }
+  // Run not yet started (e.g. because we are still finishing a previous
+  // re-clustering).
+  return progressNotYetStarted;
+};
diff --git a/analysis/frontend/ui/src/tools/react_shim.ts b/analysis/frontend/ui/src/tools/react_shim.ts
new file mode 100644
index 0000000..dc2659c
--- /dev/null
+++ b/analysis/frontend/ui/src/tools/react_shim.ts
@@ -0,0 +1,18 @@
+/**
+* Copyright 2022 The LUCI Authors.
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*      http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+import * as React from 'react';
+export { React };
diff --git a/analysis/frontend/ui/src/tools/rendering_tools.tsx b/analysis/frontend/ui/src/tools/rendering_tools.tsx
new file mode 100644
index 0000000..28b38cd
--- /dev/null
+++ b/analysis/frontend/ui/src/tools/rendering_tools.tsx
@@ -0,0 +1,30 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { ElementType } from 'react';
+
+interface Props {
+  component: ElementType
+}
+
+/**
+ * Renders a component dynamically, useful for components defined in a List or a Map.
+ *
+ * @param {ReactElement} param0  The component to render.
+ * @return {ReactElement} A renderable React component.
+ */
+export function DynamicComponentNoProps({ component }: Props) {
+  const TheComponent = component;
+  return <TheComponent />;
+}
diff --git a/analysis/frontend/ui/src/tools/urlHandling/links.ts b/analysis/frontend/ui/src/tools/urlHandling/links.ts
new file mode 100644
index 0000000..d09721d
--- /dev/null
+++ b/analysis/frontend/ui/src/tools/urlHandling/links.ts
@@ -0,0 +1,54 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { DistinctClusterFailure, Changelist } from '../../services/cluster';
+import { ClusterId } from '../../services/shared_models';
+
+export const linkToCluster = (project: string, c: ClusterId): string => {
+  if (c.algorithm.startsWith('rules-') || c.algorithm == 'rules') {
+    return linkToRule(project, c.id);
+  } else {
+    const projectEncoded = encodeURIComponent(project);
+    const algorithmEncoded = encodeURIComponent(c.algorithm);
+    const idEncoded = encodeURIComponent(c.id);
+    return `/p/${projectEncoded}/clusters/${algorithmEncoded}/${idEncoded}`;
+  }
+};
+
+export const linkToRule = (project: string, ruleId: string): string => {
+  const projectEncoded = encodeURIComponent(project);
+  const ruleIdEncoded = encodeURIComponent(ruleId);
+  return `/p/${projectEncoded}/rules/${ruleIdEncoded}`;
+};
+
+export const failureLink = (failure: DistinctClusterFailure) => {
+  const query = `ID:${failure.testId} `;
+  if (failure.ingestedInvocationId?.startsWith('build-')) {
+    return `https://ci.chromium.org/ui/b/${failure.ingestedInvocationId.replace('build-', '')}/test-results?q=${encodeURIComponent(query)}`;
+  }
+  return `https://ci.chromium.org/ui/inv/${failure.ingestedInvocationId}/test-results?q=${encodeURIComponent(query)}`;
+};
+
+export const clLink = (cl: Changelist) => {
+  return `https://${cl.host}/c/${cl.change}/${cl.patchset}`;
+};
+
+export const clName = (cl: Changelist) => {
+  const host = cl.host.replace('-review.googlesource.com', '');
+  return `${host}/${cl.change}/${cl.patchset}`;
+};
+
+export const testHistoryLink = (project: string, testId: string, query: string) => {
+  return `https://ci.chromium.org/ui/test/${encodeURIComponent(project)}/${encodeURIComponent(testId)}?q=${encodeURIComponent(query)}`;
+};
diff --git a/analysis/frontend/ui/src/types/mui_types.ts b/analysis/frontend/ui/src/types/mui_types.ts
new file mode 100644
index 0000000..b940a8b
--- /dev/null
+++ b/analysis/frontend/ui/src/types/mui_types.ts
@@ -0,0 +1,15 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+export type MuiDefaultColor = 'default' | 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning';
diff --git a/analysis/frontend/ui/src/views/bug/bug_page/bug_page.ts b/analysis/frontend/ui/src/views/bug/bug_page/bug_page.ts
new file mode 100644
index 0000000..ffdf8cf
--- /dev/null
+++ b/analysis/frontend/ui/src/views/bug/bug_page/bug_page.ts
@@ -0,0 +1,139 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+    css,
+    customElement,
+    html,
+    LitElement,
+    property,
+    state,
+    TemplateResult
+} from 'lit-element';
+import { Ref } from 'react';
+import { NavigateFunction } from 'react-router-dom';
+
+import { GrpcError } from '@chopsui/prpc-client';
+
+import {
+    getRulesService,
+    LookupBugRequest,
+    LookupBugResponse,
+    parseRuleName
+} from '../../../services/rules';
+import { linkToRule } from '../../../tools/urlHandling/links';
+
+// BugPage handles the bug endpoint:
+// /b/<bugtracker>/<bugid>
+// Where bugtracker is either 'b' for buganizer or a monorail project name.
+// It redirects to the page for the rule associated with the bug (if any).
+@customElement('bug-page')
+export class BugPage extends LitElement {
+
+    @property({ attribute: false })
+    ref: Ref<BugPage> | null = null;
+
+    @property()
+    bugTracker = '';
+
+    @property()
+    bugId = '';
+
+    navigate!: NavigateFunction;
+
+    @property()
+    system: string = '';
+
+    @property()
+    id: string = '';
+
+    @state()
+    error: any;
+
+    @state()
+    response: LookupBugResponse | null = null;
+
+    connectedCallback() {
+        super.connectedCallback();
+        this.setBug(this.bugTracker, this.bugId);
+        this.fetch();
+    }
+
+    setBug(tracker: string, id: string) {
+        if (tracker == 'b') {
+            this.system = 'buganizer';
+            this.id = id;
+        } else {
+            this.system = 'monorail';
+            this.id = tracker + '/' + id;
+        }
+    }
+
+    async fetch(): Promise<void> {
+        const service = getRulesService();
+        try {
+            const request: LookupBugRequest = {
+                system: this.system,
+                id: this.id,
+            }
+            const response = await service.lookupBug(request);
+            this.response = response;
+
+            if (response.rules && response.rules.length === 1) {
+                const ruleKey = parseRuleName(response.rules[0]);
+                const link = linkToRule(ruleKey.project, ruleKey.ruleId);
+                this.navigate(link);
+            }
+            this.requestUpdate();
+        } catch (e) {
+            this.error = e;
+        }
+    }
+
+    render() {
+        return html`<div id="container">${this.message()}</div>`
+    }
+
+    message(): TemplateResult {
+        if (this.error) {
+            if (this.error instanceof GrpcError) {
+                return html`Error finding rule for bug (${this.system}:${this.id}): ${this.error.description.trim()}.`;
+            }
+            return html`${this.error}`;
+        }
+        if (this.response) {
+            if (!this.response.rules) {
+                return html`No rule found matching the specified bug (${this.system}:${this.id}).`;
+            }
+
+            const ruleLink = (ruleName: string): string => {
+                const ruleKey = parseRuleName(ruleName);
+                return linkToRule(ruleKey.project, ruleKey.ruleId);
+            }
+
+            return html`Multiple projects have rules matching the specified bug (${this.system}:${this.id}):
+            <ul>
+                ${this.response.rules.map(r => html`<li><a href="${ruleLink(r)}">${parseRuleName(r).project}</a></li>`)}
+            </ul>
+            `
+        }
+        return html`Loading...`;
+    }
+
+    static styles = [css`
+        #container {
+            margin: 20px 14px;
+        }
+    `];
+}
diff --git a/analysis/frontend/ui/src/views/bug/bug_page/bug_page_wrapper.tsx b/analysis/frontend/ui/src/views/bug/bug_page/bug_page_wrapper.tsx
new file mode 100644
index 0000000..a1f930e
--- /dev/null
+++ b/analysis/frontend/ui/src/views/bug/bug_page/bug_page_wrapper.tsx
@@ -0,0 +1,41 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './bug_page';
+import '../../../../web_component_types';
+
+import { useCallback } from 'react';
+import {
+  useNavigate,
+  useParams,
+} from 'react-router-dom';
+
+const BugPageWrapper = () => {
+  const { bugTracker, id } = useParams();
+  const navigate = useNavigate();
+  const elementRef = useCallback((node) => {
+    if (node !== null) {
+      node.navigate = navigate;
+    }
+  }, [navigate]);
+  return (
+    <bug-page
+      bugTracker={bugTracker}
+      bugId={id}
+      ref={elementRef}
+    />
+  );
+};
+
+export default BugPageWrapper;
diff --git a/analysis/frontend/ui/src/views/clusters/cluster/cluster_page.ts b/analysis/frontend/ui/src/views/clusters/cluster/cluster_page.ts
new file mode 100644
index 0000000..c3c563b
--- /dev/null
+++ b/analysis/frontend/ui/src/views/clusters/cluster/cluster_page.ts
@@ -0,0 +1,228 @@
+
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './elements/reclustering_progress_indicator';
+import '../../../shared_elements/failure_table';
+import './elements/rule_section';
+import './elements/impact_table';
+
+import {
+    css,
+    customElement,
+    html,
+    LitElement,
+    property,
+    state,
+    TemplateResult,
+} from 'lit-element';
+import { Ref } from 'react';
+import { NavigateFunction } from 'react-router-dom';
+
+import { Cluster, BatchGetClustersRequest, getClustersService } from '../../../services/cluster';
+import { RuleChangedEvent } from './elements/rule_section';
+
+// ClusterPage lists the clusters tracked by Weetbix.
+@customElement('cluster-page')
+export class ClusterPage extends LitElement {
+
+    @property({ attribute: false })
+    ref: Ref<ClusterPage> | null = null;
+
+    @property()
+    project = '';
+
+    @property()
+    clusterAlgorithm = '';
+
+    @property()
+    clusterId = '';
+
+    navigate!: NavigateFunction;
+
+    @state()
+    cluster: Cluster | undefined;
+
+    @state()
+    // When the displayed rule's predicate (if any) was last updated.
+    // This is provided to the reclustering progress indicator to show
+    // the correct re-clustering status.
+    rulePredicateLastUpdated = '';
+
+    connectedCallback() {
+        super.connectedCallback();
+        this.rulePredicateLastUpdated = '';
+        this.refreshAnalysis();
+    }
+
+    render() {
+        const currentCluster = this.cluster;
+
+        let definitionSection = html`Loading...`;
+        if (this.clusterAlgorithm.startsWith('rules-')) {
+            definitionSection = html`
+                <rule-section project=${this.project} ruleId=${this.clusterId} @rulechanged=${this.onRuleChanged}>
+                </rule-section>
+            `;
+        } else if (currentCluster !== undefined) {
+            let criteriaName = '';
+            if (this.clusterAlgorithm.startsWith('testname-')) {
+                criteriaName = 'Test name-based clustering';
+            } else if (this.clusterAlgorithm.startsWith('reason-')) {
+                criteriaName = 'Failure reason-based clustering';
+            }
+            let newRuleButton: TemplateResult = html``;
+            if (currentCluster.equivalentFailureAssociationRule) {
+                newRuleButton = html`<mwc-button class="new-rule-button" raised @click=${this.newRuleClicked}>New Rule from Cluster</mwc-button>`;
+            }
+
+            definitionSection = html`
+            <h1>Cluster <span class="cluster-id">${this.clusterAlgorithm}/${this.clusterId}</span></h1>
+            <div class="definition-box-container">
+                <pre class="definition-box">${currentCluster.hasExample ? currentCluster.title : '(cluster no longer exists)'}</pre>
+            </div>
+            <table class="definition-table">
+                <tbody>
+                    <tr>
+                        <th>Type</th>
+                        <td>Suggested</td>
+                    </tr>
+                    <tr>
+                        <th>Algorithm</th>
+                        <td>${criteriaName}</td>
+                    </tr>
+                </tbody>
+            </table>
+            ${newRuleButton}
+            `;
+        }
+
+        let impactTable = html`Loading...`;
+        if (currentCluster !== undefined) {
+            impactTable = html`
+            <impact-table .currentCluster=${currentCluster}></impact-table>
+            `;
+        }
+
+        return html`
+        <reclustering-progress-indicator project=${this.project} ?hasrule=${this.clusterAlgorithm.startsWith('rules-')}
+            rulePredicateLastUpdated=${this.rulePredicateLastUpdated} @refreshanalysis=${this.refreshAnalysis}>
+        </reclustering-progress-indicator>
+        <div id="container">
+            ${definitionSection}
+            <h2>Impact</h2>
+            ${impactTable}
+            <h2>Recent Failures</h2>
+            <failure-table project=${this.project} clusterAlgorithm=${this.clusterAlgorithm} clusterID=${this.clusterId}>
+            </failure-table>
+        </div>
+        `;
+    }
+
+    newRuleClicked() {
+        if (!this.cluster) {
+            throw new Error('invariant violated: newRuleClicked cannot be called before cluster is loaded');
+        }
+        const projectEncoded = encodeURIComponent(this.project);
+        const ruleEncoded = encodeURIComponent(this.cluster.equivalentFailureAssociationRule || '');
+        const sourceAlgEncoded = encodeURIComponent(this.clusterAlgorithm);
+        const sourceIdEncoded = encodeURIComponent(this.clusterId);
+
+        const newRuleURL = `/p/${projectEncoded}/rules/new?rule=${ruleEncoded}&sourceAlg=${sourceAlgEncoded}&sourceId=${sourceIdEncoded}`;
+        this.navigate(newRuleURL);
+    }
+
+    // Called when the rule displayed in the rule section is loaded
+    // for the first time, or updated.
+    onRuleChanged(e: CustomEvent<RuleChangedEvent>) {
+        this.rulePredicateLastUpdated = e.detail.predicateLastUpdated;
+    }
+
+    // (Re-)loads cluster impact analysis. Called on page load or
+    // if the refresh button on the reclustering progress indicator
+    // is clicked at completion of re-clustering.
+    async refreshAnalysis() {
+        this.cluster = undefined;
+        const service = getClustersService();
+        const request: BatchGetClustersRequest = {
+            parent: `projects/${encodeURIComponent(this.project)}`,
+            names: [
+                `projects/${encodeURIComponent(this.project)}/clusters/${encodeURIComponent(this.clusterAlgorithm)}/${encodeURIComponent(this.clusterId)}`,
+            ],
+        };
+
+        const response = await service.batchGet(request);
+
+        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+        this.cluster = response.clusters![0];
+        this.requestUpdate();
+    }
+
+    static styles = [css`
+        #container {
+            margin: 20px 14px;
+        }
+        h1 {
+            font-size: 18px;
+            font-weight: normal;
+        }
+        h2 {
+            margin-top: 40px;
+            font-size: 16px;
+            font-weight: normal;
+        }
+        .cluster-id {
+            font-family: monospace;
+            font-size: 80%;
+            background-color: var(--light-active-color);
+            border: solid 1px var(--active-color);
+            border-radius: 20px;
+            padding: 2px 8px;
+        }
+        .definition-box-container {
+            margin-bottom: 20px;
+        }
+        .definition-box {
+            border: solid 1px var(--divider-color);
+            background-color: var(--block-background-color);
+            padding: 20px 14px;
+            margin: 0px;
+            display: inline-block;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
+        }
+        .new-rule-button {
+            margin-top: 10px;
+        }
+        table {
+            border-collapse: collapse;
+            max-width: 100%;
+        }
+        th {
+            font-weight: normal;
+            color: var(--greyed-out-text-color);
+            text-align: left;
+        }
+        td,th {
+            padding: 4px;
+            max-width: 80%;
+        }
+        td.number {
+            text-align: right;
+        }
+        tbody.data tr:hover {
+            background-color: var(--light-active-color);
+        }
+    `];
+}
diff --git a/analysis/frontend/ui/src/views/clusters/cluster/cluster_page_wrapper.tsx b/analysis/frontend/ui/src/views/clusters/cluster/cluster_page_wrapper.tsx
new file mode 100644
index 0000000..0663edb
--- /dev/null
+++ b/analysis/frontend/ui/src/views/clusters/cluster/cluster_page_wrapper.tsx
@@ -0,0 +1,42 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './cluster_page';
+import '../../../../web_component_types';
+
+import { useCallback } from 'react';
+import {
+  useNavigate,
+  useParams,
+} from 'react-router-dom';
+
+const ClusterPageWrapper = () => {
+  const { project, algorithm, id } = useParams();
+  const navigate = useNavigate();
+  const elementRef = useCallback((node) => {
+    if (node !== null) {
+      node.navigate = navigate;
+    }
+  }, [navigate]);
+  return (
+    <cluster-page
+      ref={elementRef}
+      project={project}
+      clusterAlgorithm={algorithm || 'rules-v2'}
+      clusterId={id}
+    />
+  );
+};
+
+export default ClusterPageWrapper;
diff --git a/analysis/frontend/ui/src/views/clusters/cluster/elements/impact_table.ts b/analysis/frontend/ui/src/views/clusters/cluster/elements/impact_table.ts
new file mode 100644
index 0000000..45a0051
--- /dev/null
+++ b/analysis/frontend/ui/src/views/clusters/cluster/elements/impact_table.ts
@@ -0,0 +1,70 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import {
+  customElement,
+  html,
+  LitElement,
+  property,
+} from 'lit-element';
+import { Ref } from 'react';
+
+import { Cluster, Counts } from '../../../../services/cluster';
+
+const metric = (counts: Counts): string => {
+  return counts.nominal || '0';
+};
+
+@customElement('impact-table')
+export class ImpactTable extends LitElement {
+  @property({ attribute: false })
+    currentCluster!: Cluster;
+
+  @property({ attribute: false })
+    ref: Ref<ImpactTable> | null = null;
+
+  render() {
+    return html`
+    <table data-testid="impact-table">
+        <thead>
+            <tr>
+                <th></th>
+                <th>1 day</th>
+                <th>3 days</th>
+                <th>7 days</th>
+            </tr>
+        </thead>
+        <tbody class="data">
+            <tr>
+                <th>User Cls Failed Presubmit</th>
+                <td class="number">${metric(this.currentCluster.userClsFailedPresubmit.oneDay)}</td>
+                <td class="number">${metric(this.currentCluster.userClsFailedPresubmit.threeDay)}</td>
+                <td class="number">${metric(this.currentCluster.userClsFailedPresubmit.sevenDay)}</td>
+            </tr>
+            <tr>
+                <th>Presubmit-Blocking Failures Exonerated</th>
+                <td class="number">${metric(this.currentCluster.criticalFailuresExonerated.oneDay)}</td>
+                <td class="number">${metric(this.currentCluster.criticalFailuresExonerated.threeDay)}</td>
+                <td class="number">${metric(this.currentCluster.criticalFailuresExonerated.sevenDay)}</td>
+            </tr>
+            <tr>
+                <th>Total Failures</th>
+                <td class="number">${metric(this.currentCluster.failures.oneDay)}</td>
+                <td class="number">${metric(this.currentCluster.failures.threeDay)}</td>
+                <td class="number">${metric(this.currentCluster.failures.sevenDay)}</td>
+            </tr>
+        </tbody>
+    </table>`;
+  }
+}
diff --git a/analysis/frontend/ui/src/views/clusters/cluster/elements/reclustering_progress_indicator.ts b/analysis/frontend/ui/src/views/clusters/cluster/elements/reclustering_progress_indicator.ts
new file mode 100644
index 0000000..bf8e7f1
--- /dev/null
+++ b/analysis/frontend/ui/src/views/clusters/cluster/elements/reclustering_progress_indicator.ts
@@ -0,0 +1,217 @@
+/* eslint-disable @typescript-eslint/indent */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@material/mwc-button';
+import '@material/mwc-circular-progress';
+
+import {
+    css,
+    customElement,
+    html,
+    LitElement,
+    property,
+    state,
+    TemplateResult
+} from 'lit-element';
+import { DateTime } from 'luxon';
+
+import { ReclusteringProgress } from '../../../../services/cluster';
+
+import {
+    fetchProgress,
+    progressToLatestAlgorithms,
+    progressToLatestConfig,
+    progressToRulesVersion,
+} from '../../../../tools/progress_tools';
+
+/**
+ * ReclusteringProgressIndicator displays the progress Weetbix is making
+ * re-clustering test results to reflect current algorithms and
+ * the current rule.
+ */
+@customElement('reclustering-progress-indicator')
+export class ReclusteringProgressIndicator extends LitElement {
+    @property()
+    project = '';
+
+    @property({ type: Boolean })
+    // Whether the cluster for which the indicator is being shown is
+    // defined by a failure association rule.
+    hasRule: boolean | undefined;
+
+    @property()
+    // The last updated time of the rule predicate which defines the
+    // cluster (if any).
+    // This should be set if hasRule is true.
+    rulePredicateLastUpdated: string | undefined;
+
+    @state()
+    progress: ReclusteringProgress | undefined;
+
+    @state()
+    lastRefreshed: DateTime | undefined;
+
+    @state()
+    // Whether the indicator should be displayed. If re-clustering
+    // is not complete, this will be set to true. It will only ever
+    // be set to false if re-clustering is complete and the user
+    // reloads cluster analysis.
+    show = false;
+
+    // The last progress shown on the UI.
+    progressPerMille = 1000;
+
+    // The ID returned by window.setInterval. Used to manage the timer
+    // used to periodically poll for status updates.
+    interval: number | undefined;
+
+    connectedCallback() {
+        super.connectedCallback();
+
+        this.interval = window.setInterval(() => {
+            this.timerTick();
+        }, 5000);
+
+        this.show = false;
+
+        this.fetch();
+    }
+
+    disconnectedCallback() {
+        super.disconnectedCallback();
+        if (this.interval !== undefined) {
+            window.clearInterval(this.interval);
+        }
+    }
+
+    // tickerTick is called periodically. Its purpose is to obtain the
+    // latest re-clustering progress if progress is not complete.
+    timerTick() {
+        // Only fetch updates if the indicator is being shown. This avoids
+        // creating server load for no appreciable UX improvement.
+        if (document.visibilityState == 'visible' &&
+            this.progressPerMille < 1000) {
+            this.fetch();
+        }
+    }
+
+    render() {
+        if (this.progress === undefined ||
+            (this.hasRule && !this.rulePredicateLastUpdated)) {
+            // Still loading.
+            return html``;
+        }
+
+        let reclusteringTarget = 'updated clustering algorithms';
+        let progressPerMille = progressToLatestAlgorithms(this.progress);
+
+        const configProgress = progressToLatestConfig(this.progress);
+        if (configProgress < progressPerMille) {
+            reclusteringTarget = 'updated clustering configuration';
+            progressPerMille = configProgress;
+        }
+
+        if (this.hasRule && this.rulePredicateLastUpdated) {
+            const ruleProgress = progressToRulesVersion(this.progress, this.rulePredicateLastUpdated);
+            if (ruleProgress < progressPerMille) {
+                reclusteringTarget = 'the latest rule definition';
+                progressPerMille = ruleProgress;
+            }
+        }
+        this.progressPerMille = progressPerMille;
+
+        if (progressPerMille >= 1000 && !this.show) {
+            return html``;
+        }
+
+        // Once shown, keep showing.
+        this.show = true;
+
+        let progressText = 'task queued';
+        if (progressPerMille >= 0) {
+            progressText = (progressPerMille / 10).toFixed(1) + '%';
+        }
+
+        let content: TemplateResult;
+        if (progressPerMille < 1000) {
+            content = html`
+            <span class="progress-description" data-cy="reclustering-progress-description">
+                Weetbix is re-clustering test results to reflect ${reclusteringTarget} (${progressText}). Cluster impact may be out-of-date.
+                <span class="last-updated">
+                    Last update ${this.lastRefreshed?.toLocaleString(DateTime.TIME_WITH_SECONDS)}.
+                </span>
+            </span>`;
+        } else {
+            content = html`
+            <span class="progress-description" data-cy="reclustering-progress-description">
+                Weetbix has finished re-clustering test results. Updated cluster impact is now available.
+            </span>
+            <mwc-button outlined @click=${this.refreshAnalysis}>
+                View Updated Impact
+            </mwc-button>`;
+        }
+
+        return html`
+        <div class="progress-box">
+            <mwc-circular-progress
+                ?indeterminate=${progressPerMille < 0}
+                progress="${Math.max(0, progressPerMille / 1000)}">
+            </mwc-circular-progress>
+            ${content}
+        </div>
+        `;
+    }
+
+    async fetch() {
+        this.progress = await fetchProgress(this.project);
+        this.lastRefreshed = DateTime.now();
+        this.requestUpdate();
+    }
+
+    refreshAnalysis() {
+        this.fireRefreshAnalysis();
+        this.show = false;
+    }
+
+    fireRefreshAnalysis() {
+        const event = new CustomEvent<RefreshAnalysisEvent>('refreshanalysis', {
+            detail: {
+            },
+        });
+        this.dispatchEvent(event);
+    }
+
+    static styles = [css`
+        .progress-box {
+            display: flex;
+            background-color: var(--light-active-color);
+            padding: 5px;
+            align-items: center;
+        }
+        .progress-description {
+            padding: 0px 10px;
+        }
+        .last-updated {
+            padding: 0px;
+            font-size: var(--font-size-small);
+            color: var(--greyed-out-text-color);
+        }
+    `];
+}
+
+// RefreshAnalysisEvent is an event that is triggered when the user requests
+// cluster analysis to be updated.
+// eslint-disable-next-line @typescript-eslint/no-empty-interface
+export interface RefreshAnalysisEvent {}
\ No newline at end of file
diff --git a/analysis/frontend/ui/src/views/clusters/cluster/elements/rule_section.ts b/analysis/frontend/ui/src/views/clusters/cluster/elements/rule_section.ts
new file mode 100644
index 0000000..7228cec
--- /dev/null
+++ b/analysis/frontend/ui/src/views/clusters/cluster/elements/rule_section.ts
@@ -0,0 +1,521 @@
+/* eslint-disable import/no-duplicates */
+/* eslint-disable @typescript-eslint/indent */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { LitElement, html, customElement, property, css, state, TemplateResult } from 'lit-element';
+import { DateTime } from 'luxon';
+import { GrpcError, RpcCode } from '@chopsui/prpc-client';
+import '@material/mwc-button';
+import '@material/mwc-dialog';
+import '@material/mwc-textfield';
+import '@material/mwc-textarea';
+import { TextArea } from '@material/mwc-textarea';
+import '@material/mwc-textfield';
+import '@material/mwc-snackbar';
+import { Snackbar } from '@material/mwc-snackbar';
+import '@material/mwc-switch';
+import { Switch } from '@material/mwc-switch';
+import '@material/mwc-icon';
+import { BugPicker } from '../../../../shared_elements/bug_picker';
+import '../../../../shared_elements/bug_picker';
+
+import { getRulesService, Rule, UpdateRuleRequest } from '../../../../services/rules';
+import { getIssuesService, Issue, GetIssueRequest } from '../../../../services/monorail';
+import { linkToCluster } from '../../../../tools/urlHandling/links';
+
+/**
+ * RuleSection displays a rule tracked by Weetbix.
+ * @fires rulechanged
+ */
+@customElement('rule-section')
+export class RuleSection extends LitElement {
+    @property()
+    project = '';
+
+    @property()
+    ruleId = '';
+
+    @state()
+    rule: Rule | null = null;
+
+    @state()
+    issue: Issue | null = null;
+
+    @state()
+    editingRule = false;
+
+    @state()
+    editingBug = false;
+
+    @state()
+    validationMessage = '';
+
+    @state()
+    snackbarError = '';
+
+    connectedCallback() {
+        super.connectedCallback();
+        this.fetch();
+    }
+
+    render() {
+        if (!this.rule) {
+            return html`Loading...`;
+        }
+        const r = this.rule;
+        const formatTime = (time: string): string => {
+            const t = DateTime.fromISO(time);
+            const d = DateTime.now().diff(t);
+            if (d.as('seconds') < 60) {
+                return 'just now';
+            }
+            if (d.as('hours') < 24) {
+                return t.toRelative()?.toLocaleLowerCase() || '';
+            }
+            return DateTime.fromISO(time).toLocaleString(DateTime.DATETIME_SHORT);
+        };
+        const formatTooltipTime = (time: string): string => {
+            // Format date/time with full month name, e.g. "January" and Timezone,
+            // to disambiguate date/time even if the user's locale has been set
+            // incorrectly.
+            return DateTime.fromISO(time).toLocaleString(DateTime.DATETIME_FULL_WITH_SECONDS);
+        };
+        const bugStatusClass = (status: string): string => {
+            // In monorail, bug statuses are configurable per system. Right now,
+            // we don't have a configurable mapping from status to semantic in
+            // Weetbix. We will try to recognise common terminology and fall
+            // back to "other" status otherwise.
+            status = status.toLowerCase();
+            const unassigned = ['new', 'untriaged', 'available'];
+            const assigned = ['accepted', 'assigned', 'started', 'externaldependency'];
+            const fixed = ['fixed', 'verified'];
+            if (unassigned.indexOf(status) >= 0) {
+                return 'bug-status-unassigned';
+            } else if (assigned.indexOf(status) >= 0) {
+                return 'bug-status-assigned';
+            } else if (fixed.indexOf(status) >= 0) {
+                return 'bug-status-fixed';
+            } else {
+                // E.g. Won't fix, duplicate, archived.
+                return 'bug-status-other';
+            }
+        };
+        const formatUser = (user: string): TemplateResult => {
+            if (user == 'weetbix') {
+                return html`Weetbix`;
+            } else if (user.endsWith('@google.com')) {
+                const ldap = user.substr(0, user.length - '@google.com'.length);
+                return html`<a href="http://who/${ldap}">${ldap}</a>`;
+            } else {
+                return html`${user}`;
+            }
+        };
+        return html`
+        <div>
+            <h1 data-cy="rule-title">${this.issue != null ? this.issue.summary : '...' }</h1>
+            <div class="definition-box-container">
+                <pre class="definition-box" data-cy="rule-definition">${r.ruleDefinition}</pre>
+                <div class="definition-edit-button">
+                    <mwc-button outlined @click="${this.editRule}" data-cy="rule-definition-edit">Edit</mwc-button>
+                </div>
+            </div>
+            <table>
+                <tbody>
+                    <tr>
+                        <th>Associated Bug</th>
+                        <td data-cy="bug">
+                            <a href="${r.bug.url}">${r.bug.linkText}</a>
+                            <div class="inline-button">
+                                <mwc-button outlined dense @click="${this.editBug}" data-cy="bug-edit">Edit</mwc-button>
+                            </div>
+                        </td>
+                    </tr>
+                    <tr>
+                        <th>Status</th>
+                        <td data-cy="bug-status">${this.issue != null ? html`<span class="bug-status ${bugStatusClass(this.issue.status.status)}">${this.issue.status.status}</span>` : html`...` }</td>
+                    </tr>
+                    <tr>
+                        <th>Bug Updates <mwc-icon class="inline-icon" title="Whether the priority and verified status of the associated bug should be automatically updated based on cluster impact. Only one rule may be set to update a given bug at any one time.">help_outline</mwc-icon></th>
+                        <td data-cy="bug-updates">
+                            <mwc-switch id="bug-updates-toggle" data-cy="bug-updates-toggle" .selected=${r.isManagingBug} @click=${this.toggleManagingBug}></mwc-switch>
+                        </td>
+                    </tr>
+                    <tr>
+                        <th>Archived <mwc-icon class="inline-icon" title="Archived failure association rules do not match failures. If a rule is no longer needed, it should be archived.">help_outline</mwc-icon></th>
+                        <td data-cy="rule-archived">
+                            ${r.isActive ? 'No' : 'Yes'}
+                            <div class="inline-button">
+                                <mwc-button outlined dense @click="${this.toggleArchived}" data-cy="rule-archived-toggle">${r.isActive ? 'Archive' : 'Restore'}</mwc-button>
+                            </div>
+                        </td>
+                    </tr>
+                    <tr>
+                        <th>Source Cluster <mwc-icon class="inline-icon" title="The cluster this rule was originally created from.">help_outline</mwc-icon></th>
+                        <td>
+                            ${r.sourceCluster.algorithm && r.sourceCluster.id ?
+                                html`<a href="${linkToCluster(this.project, r.sourceCluster)}">${r.sourceCluster.algorithm}/${r.sourceCluster.id}</a>` :
+                                html`None`
+                            }
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+            <div class="audit">
+                ${(r.lastUpdateTime != r.createTime) ?
+                html`Last updated by <span class="user">${formatUser(r.lastUpdateUser)}</span> <span class="time" title="${formatTooltipTime(r.lastUpdateTime)}">${formatTime(r.lastUpdateTime)}</span>.` : html``}
+                Created by <span class="user">${formatUser(r.createUser)}</span> <span class="time" title="${formatTooltipTime(r.createTime)}">${formatTime(r.createTime)}</span>.
+            </div>
+        </div>
+        <mwc-dialog class="rule-edit-dialog" .open="${this.editingRule}" @closed="${this.editRuleClosed}">
+            <div class="edit-title">Edit Rule Definition <mwc-icon class="inline-icon" title="Weetbix rule definitions describe the failures associated with a bug. Rules follow a subset of BigQuery Standard SQL's boolean expression syntax.">help_outline</mwc-icon></div>
+            <div class="validation-error" data-cy="rule-definition-validation-error">${this.validationMessage}</div>
+            <mwc-textarea id="rule-definition" label="Rule Definition" maxLength="4096" required data-cy="rule-definition-textbox"></mwc-textarea>
+            <div>
+                Supported is AND, OR, =, <>, NOT, IN, LIKE, parentheses and <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators#regexp_contains">REGEXP_CONTAINS</a>.
+                Valid identifiers are <em>test</em> and <em>reason</em>.
+            </div>
+            <mwc-button slot="primaryAction" @click="${this.saveRule}" data-cy="rule-definition-save">Save</mwc-button>
+            <mwc-button slot="secondaryAction" dialogAction="close" data-cy="rule-definition-cancel">Cancel</mwc-button>
+        </mwc-dialog>
+        <mwc-dialog class="bug-edit-dialog" .open="${this.editingBug}" @closed="${this.editBugClosed}">
+            <div class="edit-title">Edit Associated Bug</div>
+            <div class="validation-error" data-cy="bug-validation-error">${this.validationMessage}</div>
+            <bug-picker id="bug" project="${this.project}" material832Workaround></bug-picker>
+            <mwc-button slot="primaryAction" @click="${this.saveBug}" data-cy="bug-save">Save</mwc-button>
+            <mwc-button slot="secondaryAction" dialogAction="close" data-cy="bug-cancel">Cancel</mwc-button>
+        </mwc-dialog>
+        <mwc-snackbar id="error-snackbar" labelText="${this.snackbarError}"></mwc-snackbar>
+        `;
+    }
+
+    async fetch() {
+        if (!this.ruleId) {
+            throw new Error('rule-section element ruleID property is required');
+        }
+        const service = getRulesService();
+        const rule = await service.get({
+            name: `projects/${this.project}/rules/${this.ruleId}`
+        });
+
+        this.rule = rule;
+        this.fireRuleChanged();
+        this.fetchBug(rule);
+        this.requestUpdate();
+    }
+
+    async fetchBug(rule: Rule) {
+        if (rule.bug.system === 'monorail') {
+            const parts = rule.bug.id.split('/');
+            const monorailProject = parts[0];
+            const bugId = parts[1];
+            const issueId = `projects/${monorailProject}/issues/${bugId}`;
+            if (this.issue != null && this.issue.name == issueId) {
+                // No update required.
+                return;
+            }
+            this.issue = null;
+            const service = getIssuesService();
+            const request: GetIssueRequest = {
+                name: issueId
+            };
+            const issue = await service.getIssue(request);
+            this.issue = issue;
+        } else {
+            this.issue = null;
+        }
+        this.requestUpdate();
+    }
+
+    editRule() {
+        if (!this.rule) {
+            throw new Error('invariant violated: editRule cannot be called before rule is loaded');
+        }
+        const ruleDefinition = this.shadowRoot!.getElementById('rule-definition') as TextArea;
+        ruleDefinition.value = this.rule.ruleDefinition;
+
+        this.editingRule = true;
+        this.validationMessage = '';
+    }
+
+    editBug() {
+        if (!this.rule) {
+            throw new Error('invariant violated: editBug cannot be called before rule is loaded');
+        }
+        const picker = this.shadowRoot!.getElementById('bug') as BugPicker;
+        picker.bugSystem = this.rule.bug.system;
+        picker.bugId = this.rule.bug.id;
+
+        this.editingBug = true;
+        this.validationMessage = '';
+    }
+
+    editRuleClosed() {
+        this.editingRule = false;
+    }
+
+    editBugClosed() {
+        this.editingBug = false;
+    }
+
+    async saveRule() {
+        if (!this.rule) {
+            throw new Error('invariant violated: saveRule cannot be called before rule is loaded');
+        }
+        const ruleDefinition = this.shadowRoot!.getElementById('rule-definition') as TextArea;
+        if (ruleDefinition.value == this.rule.ruleDefinition) {
+            this.editingRule = false;
+            return;
+        }
+
+        this.validationMessage = '';
+
+        const request: UpdateRuleRequest = {
+            rule: {
+                name: this.rule.name,
+                ruleDefinition: ruleDefinition.value,
+            },
+            updateMask: 'ruleDefinition',
+            etag: this.rule.etag,
+        };
+
+        try {
+            await this.applyUpdate(request);
+            this.editingRule = false;
+        } catch (e) {
+            this.routeUpdateError(e);
+        }
+        this.requestUpdate();
+    }
+
+    async saveBug() {
+        if (!this.rule) {
+            throw new Error('invariant violated: saveBug cannot be called before rule is loaded');
+        }
+        const picker = this.shadowRoot!.getElementById('bug') as BugPicker;
+        if (picker.bugSystem === this.rule.bug.system && picker.bugId === this.rule.bug.id) {
+            this.editingBug = false;
+            return;
+        }
+
+        this.validationMessage = '';
+
+        const request: UpdateRuleRequest = {
+            rule: {
+                name: this.rule.name,
+                bug: {
+                    system: picker.bugSystem,
+                    id: picker.bugId,
+                },
+            },
+            updateMask: 'bug',
+            etag: this.rule.etag,
+        };
+
+        try {
+            await this.applyUpdate(request);
+            this.editingBug = false;
+            this.requestUpdate();
+        } catch (e) {
+            this.routeUpdateError(e);
+        }
+    }
+
+    // routeUpdateError is used to handle update errors that occur in the
+    // context of a model dialog, where a validation err message can be
+    // displayed.
+    routeUpdateError(e: any) {
+        if (e instanceof GrpcError) {
+            if (e.code === RpcCode.INVALID_ARGUMENT) {
+                this.validationMessage = 'Validation error: ' + e.description.trim() + '.';
+                return;
+            }
+        }
+        this.showSnackbar(e as string);
+    }
+
+    async toggleArchived() {
+        if (!this.rule) {
+            throw new Error('invariant violated: toggleActive cannot be called before rule is loaded');
+        }
+        const request: UpdateRuleRequest = {
+            rule: {
+                name: this.rule.name,
+                isActive: !this.rule.isActive,
+            },
+            updateMask: 'isActive',
+            etag: this.rule.etag,
+        };
+        try {
+            await this.applyUpdate(request);
+            this.requestUpdate();
+        } catch (err) {
+            this.showSnackbar(err as string);
+        }
+    }
+
+    async toggleManagingBug() {
+        if (!this.rule) {
+            throw new Error('invariant violated: toggleManagingBug cannot be called before rule is loaded');
+        }
+
+        this.requestUpdate();
+        // Revert the automatic toggle caused by the click.
+        const toggle = this.shadowRoot!.getElementById('bug-updates-toggle') as Switch;
+        toggle.selected = this.rule.isManagingBug;
+
+        const request: UpdateRuleRequest = {
+            rule: {
+                name: this.rule.name,
+                isManagingBug: !this.rule.isManagingBug,
+            },
+            updateMask: 'isManagingBug',
+            etag: this.rule.etag,
+        };
+        try {
+            await this.applyUpdate(request);
+            this.requestUpdate();
+        } catch (err) {
+            this.showSnackbar(err as string);
+        }
+    }
+
+    // applyUpdate tries to apply the given update to the rule. If the
+    // update succeeds, this method returns nil. If a validation error
+    // occurs, the validation message is returned.
+    async applyUpdate(request: UpdateRuleRequest) : Promise<void> {
+        const service = getRulesService();
+        const rule = await service.update(request);
+        this.rule = rule;
+        this.fireRuleChanged();
+        this.fetchBug(rule);
+        this.requestUpdate();
+    }
+
+    showSnackbar(error: string) {
+        this.snackbarError = 'Updating rule: ' + error;
+
+        // Let the snackbar manage its own closure after a delay.
+        const snackbar = this.shadowRoot!.getElementById('error-snackbar') as Snackbar;
+        snackbar.show();
+    }
+
+    fireRuleChanged() {
+        if (!this.rule) {
+            throw new Error('invariant violated: fireRuleChanged cannot be called before rule is loaded');
+        }
+        const event = new CustomEvent<RuleChangedEvent>('rulechanged', {
+            detail: {
+                predicateLastUpdated: this.rule.predicateLastUpdateTime,
+            },
+        });
+        this.dispatchEvent(event);
+    }
+
+    static styles = [css`
+        h1 {
+            font-size: 18px;
+            font-weight: normal;
+            white-space: nowrap;
+            overflow: hidden;
+            text-overflow: ellipsis;
+        }
+        span.bug-status {
+            color: white;
+            border-radius: 20px;
+            padding: 2px 8px;
+        }
+        /* Open an unassigned. E.g. New, Untriaged, Available bugs. */
+        span.bug-status-unassigned {
+            background-color: #b71c1c;
+        }
+        /* Open and assigned. E.g. Assigned, Started. */
+        span.bug-status-assigned {
+            background-color: #0d47a1;
+        }
+        /* Closed and fixed. E.g. Fixed, Verified. */
+        span.bug-status-fixed {
+            background-color: #2e7d32;
+        }
+        /* Closed and not fixed and other statuses we don't recognise.
+           E.g. Won't fix, Duplicate, Archived */
+        span.bug-status-other {
+            background-color: #000000;
+        }
+        .inline-button {
+            display: inline-block;
+            vertical-align: middle;
+            padding-left: 5px;
+        }
+        .inline-icon {
+            vertical-align: middle;
+            font-size: 1.5em;
+        }
+        .edit-title {
+            margin-bottom: 10px;
+        }
+        .rule-edit-dialog {
+            --mdc-dialog-min-width:1000px
+        }
+        .validation-error {
+            color: var(--mdc-theme-error, #b00020);
+        }
+        #rule-definition {
+            width: 100%;
+            height: 160px;
+        }
+        .definition-box-container {
+            display: flex;
+            margin-bottom: 20px;
+        }
+        .definition-box {
+            border: solid 1px var(--divider-color);
+            background-color: var(--block-background-color);
+            padding: 20px 14px;
+            margin: 0px;
+            display: inline-block;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
+        }
+        .definition-edit-button {
+            align-self: center;
+            margin: 5px;
+        }
+        table {
+            border-collapse: collapse;
+            max-width: 100%;
+        }
+        th {
+            font-weight: normal;
+            color: var(--greyed-out-text-color);
+            text-align: left;
+        }
+        td,th {
+            padding: 4px;
+            max-width: 80%;
+            height: 28px;
+        }
+        mwc-textarea, bug-picker {
+            margin: 5px 0px;
+        }
+        .audit {
+            font-size: var(--font-size-small);
+            color: var(--greyed-out-text-color);
+        }
+    `];
+}
+
+export interface RuleChangedEvent {
+    predicateLastUpdated: string; // RFC 3339 encoded date/time.
+}
diff --git a/analysis/frontend/ui/src/views/clusters/clusters_page.tsx b/analysis/frontend/ui/src/views/clusters/clusters_page.tsx
new file mode 100644
index 0000000..04d96cf
--- /dev/null
+++ b/analysis/frontend/ui/src/views/clusters/clusters_page.tsx
@@ -0,0 +1,44 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useParams } from 'react-router-dom';
+
+import Grid from '@mui/material/Grid';
+import Container from '@mui/material/Container';
+import HelpTooltip from '../../components/help_tooltip/help_tooltip';
+import ClustersTable from '../../components/clusters_table/clusters_table';
+
+const rulesDescription = 'Clusters are groups of related test failures. Weetbix\'s clusters ' +
+  'comprise clusters identified by algorithms (based on test name or failure reason) ' +
+  'and clusters defined by a failure association rule (where the cluster contains all failures ' +
+  'associated with a specific bug).';
+
+const ClustersPage = () => {
+  const { project } = useParams();
+  return (
+    <Container maxWidth={false}>
+      <Grid container>
+        <Grid item xs={8}>
+          <h2>Clusters in project {project}<HelpTooltip text={rulesDescription}></HelpTooltip></h2>
+        </Grid>
+      </Grid>
+      {(project) && (
+        <ClustersTable project={project}></ClustersTable>
+      )}
+    </Container>
+  );
+};
+
+export default ClustersPage;
+
diff --git a/analysis/frontend/ui/src/views/errors/not_found_page.tsx b/analysis/frontend/ui/src/views/errors/not_found_page.tsx
new file mode 100644
index 0000000..1cd6f76
--- /dev/null
+++ b/analysis/frontend/ui/src/views/errors/not_found_page.tsx
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+
+const NotFoundPage = () => {
+  return (
+    <p>
+        Page not found
+    </p>
+  );
+};
+
+export default NotFoundPage;
+
diff --git a/analysis/frontend/ui/src/views/home/elements/project_card.ts b/analysis/frontend/ui/src/views/home/elements/project_card.ts
new file mode 100644
index 0000000..5033c51
--- /dev/null
+++ b/analysis/frontend/ui/src/views/home/elements/project_card.ts
@@ -0,0 +1,66 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@material/mwc-circular-progress/mwc-circular-progress';
+
+import {
+    css,
+    customElement,
+    html,
+    LitElement,
+    property
+} from 'lit-element';
+
+import { Project } from '../../../services/project';
+
+@customElement('project-card')
+export class ProjectCard extends LitElement {
+
+    @property({attribute: false})
+    project: Project | null = null;
+
+    protected render() {
+        if (this.project == null) {
+            return html`
+            <mwc-circular-progress></mwc-circular-progress>
+            `;
+        } else {
+            return html`
+            <a id="card" href="/p/${this.project.project}/clusters">
+                ${this.project.displayName}
+            </a>
+            `;
+        }
+    }
+
+    static styles = css`
+    #card {
+        padding: 1rem;
+        display: flex;
+        justify-content: center;
+        align-items: center;
+        box-shadow: 0px 2px 1px -1px rgb(0 0 0 / 20%), 0px 1px 1px 0px rgb(0 0 0 / 14%), 0px 1px 3px 0px rgb(0 0 0 / 12%);
+        font-weight: bold;
+        font-size: 1.5rem;
+        text-decoration: none;
+        color: black;
+        border-radius: 4px;
+        transition: transform .2s;
+        height: 100%;
+    }
+    #card:hover {
+        transform: scale(1.1);
+    }
+    `;
+}
\ No newline at end of file
diff --git a/analysis/frontend/ui/src/views/home/home_page.ts b/analysis/frontend/ui/src/views/home/home_page.ts
new file mode 100644
index 0000000..9bf28d4
--- /dev/null
+++ b/analysis/frontend/ui/src/views/home/home_page.ts
@@ -0,0 +1,86 @@
+/* eslint-disable @typescript-eslint/indent */
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './elements/project_card';
+
+import {
+    css,
+    customElement,
+    html,
+    LitElement,
+    state
+} from 'lit-element';
+
+import {
+    getProjectsService,
+    ListProjectsRequest,
+    Project
+} from '../../services/project';
+
+/**
+ *  Represents the home page where the user selects their project.
+ */
+@customElement('home-page')
+export class HomePage extends LitElement {
+
+    @state()
+    projects: Project[] | null = [];
+
+    connectedCallback() {
+        super.connectedCallback();
+        this.fetch();
+    }
+
+    async fetch() {
+        const service = getProjectsService();
+        const request: ListProjectsRequest = {};
+        const response = await service.list(request);
+        // Chromium milestone projects are explicitly ignored by the backend, match this in the frontend.
+        this.projects = response.projects?.filter(p => !/^(chromium|chrome)-m[0-9]+$/.test(p.project)) || null;
+        this.requestUpdate();
+    }
+
+    render() {
+        return html`
+        <main id="container">
+            <section id="title">
+                <h1>Projects</h1>
+            </section>
+            <section id="projects">
+                ${this.projects?.map((project) => {
+                    return html`
+                    <project-card .project=${project}></project-card>
+                    `;
+                })}
+            </section>
+        </main>
+        `;
+    }
+
+    static styles = css`
+    #container {
+        margin-left: 16rem;
+        margin-right: 16rem;
+    }
+
+    #projects {
+        margin: auto;
+        display: grid;
+        grid-template-columns: repeat(6, 1fr);
+        gap: 2rem;
+    }
+    `;
+
+}
diff --git a/analysis/frontend/ui/src/views/home/home_page_wrapper.tsx b/analysis/frontend/ui/src/views/home/home_page_wrapper.tsx
new file mode 100644
index 0000000..605944c
--- /dev/null
+++ b/analysis/frontend/ui/src/views/home/home_page_wrapper.tsx
@@ -0,0 +1,22 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './home_page';
+import '../../../web_component_types';
+
+const HomePageWrapper = () => {
+  return (<home-page></home-page>);
+};
+
+export default HomePageWrapper;
diff --git a/analysis/frontend/ui/src/views/new_rule/new_rule_page.ts b/analysis/frontend/ui/src/views/new_rule/new_rule_page.ts
new file mode 100644
index 0000000..1b14560
--- /dev/null
+++ b/analysis/frontend/ui/src/views/new_rule/new_rule_page.ts
@@ -0,0 +1,224 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import '@material/mwc-button';
+import '@material/mwc-dialog';
+import '@material/mwc-icon';
+
+import {
+    css,
+    customElement,
+    html,
+    LitElement,
+    property,
+    state
+} from 'lit-element';
+import { Ref } from 'react';
+import { NavigateFunction } from 'react-router-dom';
+import { URLSearchParams } from 'url';
+
+import {
+    GrpcError,
+    RpcCode
+} from '@chopsui/prpc-client';
+import { Snackbar } from '@material/mwc-snackbar';
+import { TextArea } from '@material/mwc-textarea';
+
+import { ClusterId } from '../../services/shared_models';
+import {
+    CreateRuleRequest,
+    getRulesService
+} from '../../services/rules';
+import { BugPicker } from '../../shared_elements/bug_picker';
+import { linkToRule } from '../../tools/urlHandling/links';
+
+/**
+ * NewRulePage displays a page for creating a new rule in Weetbix.
+ * This is implemented as a page and not a pop-up dialog, as it will make it
+ * easier for external integrations that want to link to the new rule
+ * page in Weetbix from a failure (e.g. from a failure in MILO).
+ */
+@customElement('new-rule-page')
+export class NewRulePage extends LitElement {
+    @property()
+    project = '';
+
+    @property()
+    ruleString!: string | null;
+
+    @property()
+    sourceAlg!: string | null;
+
+    @property()
+    sourceId!: string | null;
+
+    @property({ attribute: false })
+    ref: Ref<NewRulePage> | null = null;
+
+    navigate!: NavigateFunction;
+
+    searchParams!: URLSearchParams;
+
+    @state()
+    validationMessage = '';
+
+    @state()
+    defaultRule = '';
+
+    @state()
+    sourceCluster: ClusterId = { algorithm: '', id: '' };
+
+    @state()
+    snackbarError = '';
+
+    updateRuleAndClusterFromSearch() {
+        let rule = this.ruleString;
+        if (rule) {
+            this.defaultRule = rule;
+        }
+        let sourceClusterAlg = this.sourceAlg;
+        let sourceClusterID = this.sourceId;
+        if (sourceClusterAlg && sourceClusterID) {
+            this.sourceCluster = {
+                algorithm: sourceClusterAlg,
+                id: sourceClusterID,
+            }
+        }
+    }
+
+    connectedCallback() {
+        super.connectedCallback();
+        this.updateRuleAndClusterFromSearch();
+        this.validationMessage = '';
+        this.snackbarError = '';
+    }
+
+    render() {
+        return html`
+        <div id="container">
+            <h1>New Rule</h1>
+            <div class="validation-error" data-cy="rule-definition-validation-error">${this.validationMessage}</div>
+            <div class="label">Associated Bug <mwc-icon class="inline-icon" title="The bug corresponding to the specified failures.">help_outline</mwc-icon></div>
+            <bug-picker project="${this.project}" id="bug"></bug-picker>
+            <div class="label">Rule Definition <mwc-icon class="inline-icon" title="A rule describing the set of failures being associated. Rules follow a subset of BigQuery Standard SQL's boolean expression syntax.">help_outline</mwc-icon></div>
+            <div class="info">
+                E.g. reason LIKE "%something blew up%" or test = "mytest". Supported is AND, OR, =, <>, NOT, IN, LIKE, parentheses and <a href="https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-and-operators#regexp_contains">REGEXP_CONTAINS</a>.
+            </div>
+            <mwc-textarea id="rule-definition" label="Definition" maxLength="4096" required data-cy="rule-definition-textbox" value=${this.defaultRule}></mwc-textarea>
+            <mwc-button id="create-button" raised @click="${this.save}" data-cy="create-button">Create</mwc-button>
+            <mwc-snackbar id="error-snackbar" labelText="${this.snackbarError}"></mwc-snackbar>
+        </div>
+        `;
+    }
+
+    async save() {
+        const ruleDefinition = this.shadowRoot!.getElementById('rule-definition') as TextArea;
+        const bugPicker = this.shadowRoot!.getElementById('bug') as BugPicker;
+
+        this.validationMessage = '';
+
+        const request: CreateRuleRequest = {
+            parent: `projects/${this.project}`,
+            rule: {
+                bug: {
+                    system: bugPicker.bugSystem,
+                    id: bugPicker.bugId,
+                },
+                ruleDefinition: ruleDefinition.value,
+                isActive: true,
+                isManagingBug: true,
+                sourceCluster: this.sourceCluster,
+            },
+        }
+
+        const service = getRulesService();
+        try {
+            const rule = await service.create(request);
+            this.validationMessage = JSON.stringify(rule);
+            // Apparently .<>? doesn't work with a function
+            this.navigate(linkToRule(rule.project, rule.ruleId));
+            this.requestUpdate();
+        } catch (e) {
+            let handled = false;
+            if (e instanceof GrpcError) {
+                if (e.code === RpcCode.INVALID_ARGUMENT) {
+                    handled = true;
+                    this.validationMessage = 'Validation error: ' + e.description.trim() + '.';
+                }
+            }
+            if (!handled) {
+                this.showSnackbar(e as string);
+            }
+        }
+    }
+
+    showSnackbar(error: string) {
+        this.snackbarError = "Creating rule: " + error;
+
+        // Let the snackbar manage its own closure after a delay.
+        const snackbar = this.shadowRoot!.getElementById("error-snackbar") as Snackbar;
+        snackbar.show();
+    }
+
+    static styles = [css`
+        #container {
+            margin: 20px 14px;
+        }
+        #rule-definition {
+            width: 100%;
+            height: 160px;
+        }
+        #create-button {
+            margin-top: 10px;
+            float: right;
+        }
+        h1 {
+            font-size: 18px;
+            font-weight: normal;
+        }
+        .inline-button {
+            display: inline-block;
+            vertical-align: middle;
+        }
+        .inline-icon {
+            vertical-align: middle;
+            font-size: 1.5em;
+        }
+        .title {
+            margin-bottom: 0px;
+        }
+        .label {
+            margin-top: 15px;
+        }
+        .info {
+            color: var(--light-text-color);
+        }
+        .validation-error {
+            margin-top: 10px;
+            color: var(--mdc-theme-error, #b00020);
+        }
+        .definition-box {
+            border: solid 1px var(--divider-color);
+            background-color: var(--block-background-color);
+            padding: 20px 14px;
+            margin: 0px;
+            display: inline-block;
+            white-space: pre-wrap;
+            overflow-wrap: anywhere;
+        }
+        mwc-textarea, bug-picker {
+            margin: 5px 0px;
+        }
+    `];
+}
diff --git a/analysis/frontend/ui/src/views/new_rule/new_rule_page_wrapper.tsx b/analysis/frontend/ui/src/views/new_rule/new_rule_page_wrapper.tsx
new file mode 100644
index 0000000..582dcc1
--- /dev/null
+++ b/analysis/frontend/ui/src/views/new_rule/new_rule_page_wrapper.tsx
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import './new_rule_page';
+import '../../../web_component_types';
+
+import { useCallback } from 'react';
+import {
+  useNavigate,
+  useParams,
+  useSearchParams,
+} from 'react-router-dom';
+
+const NewRulePageWrapper = () => {
+  const { project } = useParams();
+  const navigate = useNavigate();
+  const [searchParams] = useSearchParams();
+
+  // This is a way to pass functionality from react to web-components.
+  // This strategy, however, does not work if the functionality
+  // is required when the component is initialising.
+  const elementRef = useCallback((node) => {
+    if (node !== null) {
+      node.navigate = navigate;
+    }
+  }, [navigate]);
+  return (
+    <new-rule-page
+      project={project}
+      ref={elementRef}
+      ruleString={searchParams.get('rule')}
+      sourceAlg={searchParams.get('sourceAlg')}
+      sourceId={searchParams.get('sourceId')}
+    />
+  );
+};
+
+export default NewRulePageWrapper;
diff --git a/analysis/frontend/ui/src/views/rule/rule.tsx b/analysis/frontend/ui/src/views/rule/rule.tsx
new file mode 100644
index 0000000..b11e17e
--- /dev/null
+++ b/analysis/frontend/ui/src/views/rule/rule.tsx
@@ -0,0 +1,46 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useParams } from 'react-router-dom';
+
+import Container from '@mui/material/Container';
+import Grid from '@mui/material/Grid';
+
+import ImpactSection from '../../components/impact_section/impact_section';
+import RecentFailuresSection from '../../components/recent_failures_section/recent_failures_section';
+import RuleTopPanel from '../../components/rule/rule_top_panel/rule_top_panel';
+
+const Rule = () => {
+  const { project, id } = useParams();
+
+  return (
+    <Container className='mt-1' maxWidth={false}>
+      <Grid sx={{ mt: 1 }} container spacing={2}>
+        <Grid item xs={12}>
+          {(project && id) && (
+            <RuleTopPanel project={project} ruleId={id} />
+          )}
+        </Grid>
+        <Grid item xs={12}>
+          <ImpactSection />
+        </Grid>
+        <Grid item xs={12}>
+          <RecentFailuresSection />
+        </Grid>
+      </Grid>
+    </Container>
+  );
+};
+
+export default Rule;
diff --git a/analysis/frontend/ui/src/views/rules/rules_page.tsx b/analysis/frontend/ui/src/views/rules/rules_page.tsx
new file mode 100644
index 0000000..045bb2b
--- /dev/null
+++ b/analysis/frontend/ui/src/views/rules/rules_page.tsx
@@ -0,0 +1,47 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useParams, Link } from 'react-router-dom';
+
+import Button from '@mui/material/Button';
+import Grid from '@mui/material/Grid';
+import Container from '@mui/material/Container';
+import HelpTooltip from '../../components/help_tooltip/help_tooltip';
+import RulesTable from '../../components/rules_table/rules_table';
+
+const rulesDescription = 'Rules define an association between failures and bugs. Weetbix uses these ' +
+  'associations to calculate bug impact, automatically adjust bug priority and verified status, and ' +
+  'to surface bugs for failures in the MILO test results UI.';
+
+const RulesPage = () => {
+  const { project } = useParams();
+  return (
+    <Container maxWidth={false}>
+      <Grid container>
+        <Grid item xs={8}>
+          <h2>Rules in project {project}<HelpTooltip text={rulesDescription}></HelpTooltip></h2>
+        </Grid>
+        <Grid item xs={4} sx={{ textAlign: 'right' }}>
+          <Button component={Link} variant='contained' to='new' sx={{ marginBlockStart: '20px' }}>New Rule</Button>
+        </Grid>
+      </Grid>
+      {(project) && (
+        <RulesTable project={project}></RulesTable>
+      )}
+    </Container>
+  );
+};
+
+export default RulesPage;
+
diff --git a/analysis/frontend/ui/stylelint.config.cjs b/analysis/frontend/ui/stylelint.config.cjs
new file mode 100644
index 0000000..80447bf
--- /dev/null
+++ b/analysis/frontend/ui/stylelint.config.cjs
@@ -0,0 +1,40 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+/* eslint-env node */
+
+module.exports = {
+  extends: [
+    'stylelint-config-recommended',
+    'stylelint-config-standard',
+    'stylelint-config-recess-order',
+    'stylelint-config-css-modules',
+  ],
+  plugins: ['stylelint-scss'],
+  ignoreFiles: ['./coverage/**/*.css', './dist/**/*.css'],
+  rules: {
+    // --------
+    // SCSS rules
+    // --------
+    'scss/dollar-variable-colon-space-before': 'never',
+    'scss/dollar-variable-colon-space-after': 'always',
+    'scss/dollar-variable-no-missing-interpolation': true,
+    'scss/dollar-variable-pattern': /^[a-z-]+$/,
+    'scss/double-slash-comment-whitespace-inside': 'always',
+    'scss/operator-no-newline-before': true,
+    'scss/operator-no-unspaced': true,
+    'scss/selector-no-redundant-nesting-selector': true,
+    // Allow SCSS and CSS module keywords beginning with `@`
+    'scss/at-rule-no-unknown': null,
+  },
+};
diff --git a/analysis/frontend/ui/styles/style.css b/analysis/frontend/ui/styles/style.css
new file mode 100644
index 0000000..3d93c80
--- /dev/null
+++ b/analysis/frontend/ui/styles/style.css
@@ -0,0 +1,66 @@
+/**
+* Copyright 2022 The LUCI Authors.
+*
+* Licensed under the Apache License, Version 2.0 (the "License");
+* you may not use this file except in compliance with the License.
+* You may obtain a copy of the License at
+*
+*      http://www.apache.org/licenses/LICENSE-2.0
+*
+* Unless required by applicable law or agreed to in writing, software
+* distributed under the License is distributed on an "AS IS" BASIS,
+* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+* See the License for the specific language governing permissions and
+* limitations under the License.
+*/
+
+@import "@fontsource/roboto/300.css";
+@import "@fontsource/roboto/400.css";
+@import "@fontsource/roboto/500.css";
+@import "@fontsource/roboto/700.css";
+
+body {
+  box-sizing: border-box;
+  min-width: 300px;
+  padding: 0;
+  margin: 0;
+  font-family: Roboto, "Helvetica Neue", sans-serif;
+  font-size: var(--font-size-default);
+  line-height: 1.4;
+  color: var(--default-text-color);
+  letter-spacing: 0.25px;
+  background-color: rgb(249 250 251);
+}
+
+html {
+  /* These colors are from MILO */
+  --default-text-color: #212121;
+  --light-text-color: #636363;
+  --greyed-out-text-color: #808080;
+  --active-text-color: #1976d2;
+  --exonerated-color: #0084ff;
+  --success-color: #169c16;
+  --failure-color: #d23a2d;
+  --warning-text-color: #f90;
+  --warning-color: #ffae00;
+  --scheduled-color: #73808c;
+  --started-color: #ff8000;
+  --critical-failure-color: #6c40bf;
+  --canceled-color: var(--exonerated-color);
+  --scheduled-bg-color: #b7c1c9;
+  --started-bg-color: #ffd1a2;
+  --success-bg-color: #97ddbb;
+  --failure-bg-color: #ffc9c5;
+  --critical-failure-bg-color: #dfceff;
+  --canceled-bg-color: #c5ecff;
+  --block-background-color: #f5f5f5;
+  --dark-background-color: #555;
+  --highlight-background-color: var(--warning-color);
+  --divider-color: #ddd;
+  --active-color: #007bff;
+  --light-active-color: #d9edfc;
+  --delete-color: var(--failure-color);
+  --mdc-theme-primary: var(--active-color);
+  --font-size-default: 14px;
+  --font-size-small: 12px;
+}
diff --git a/analysis/frontend/ui/tsconfig.build.json b/analysis/frontend/ui/tsconfig.build.json
new file mode 100644
index 0000000..e63daed
--- /dev/null
+++ b/analysis/frontend/ui/tsconfig.build.json
@@ -0,0 +1,14 @@
+{
+    "extends": "./tsconfig.json",
+    "compilerOptions": {
+        "module": "es2020",
+    },
+    "exclude": [
+        "node_modules",
+        "index_test.ts",
+        "src/**/*_test.ts",
+        "karma.conf.ts",
+        "webpack.*.ts",
+        "cypress"
+    ]
+}
\ No newline at end of file
diff --git a/analysis/frontend/ui/tsconfig.json b/analysis/frontend/ui/tsconfig.json
new file mode 100644
index 0000000..f41f54b
--- /dev/null
+++ b/analysis/frontend/ui/tsconfig.json
@@ -0,0 +1,30 @@
+{
+    "compilerOptions": {
+        "target": "esnext",
+        "module": "commonjs",
+        "allowJs": true,
+        "esModuleInterop": true,
+        "noUnusedLocals": true,
+        "noUnusedParameters": true,
+        "noImplicitReturns": true,
+        "noFallthroughCasesInSwitch": true,
+        "strict": true,
+        "moduleResolution": "node",
+        "resolveJsonModule": true,
+        "isolatedModules": true,
+        "allowSyntheticDefaultImports": true,
+        "experimentalDecorators": true,
+        "noEmit": true,
+        "jsx": "react-jsx",
+        "lib": ["dom", "dom.iterable", "esnext"],
+        "plugins": [
+            {
+                "name": "ts-lit-plugin",
+                "strict": true
+            }
+        ]
+    },
+    "include": [
+        "src/**/*.ts", "src/**/*.tsx",
+    ],
+}
\ No newline at end of file
diff --git a/analysis/frontend/ui/web_component_types.ts b/analysis/frontend/ui/web_component_types.ts
new file mode 100644
index 0000000..bfdc208
--- /dev/null
+++ b/analysis/frontend/ui/web_component_types.ts
@@ -0,0 +1,39 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+/* eslint-disable @typescript-eslint/no-namespace */
+import { DOMAttributes } from 'react';
+
+import { FailureTable } from './src/shared_elements/failure_table';
+import { BugPage } from './src/views/bug/bug_page/bug_page';
+import { ClusterPage } from './src/views/clusters/cluster/cluster_page';
+import { ImpactTable } from './src/views/clusters/cluster/elements/impact_table';
+import { HomePage } from './src/views/home/home_page';
+import { NewRulePage } from './src/views/new_rule/new_rule_page';
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type CustomElement<T> = Partial<T & DOMAttributes<T> & { children: any }>;
+
+declare global {
+    namespace JSX {
+        interface IntrinsicElements {
+            ['home-page']: CustomElement<HomePage>;
+            ['new-rule-page']: CustomElement<NewRulePage>;
+            ['cluster-page']: CustomElement<ClusterPage>;
+            ['bug-page']: CustomElement<BugPage>;
+            ['impact-table']: CustomElement<ImpactTable>,
+            ['failure-table']: CustomElement<FailureTable>,
+        }
+    }
+}
diff --git a/analysis/internal/admin/proto/admin.pb.go b/analysis/internal/admin/proto/admin.pb.go
new file mode 100644
index 0000000..f25cad7
--- /dev/null
+++ b/analysis/internal/admin/proto/admin.pb.go
@@ -0,0 +1,236 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.27.1
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/admin/proto/admin.proto
+
+package adminpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type ExportTestVariantsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// LUCI Realm. Test variants in this realm are exported.
+	Realm string `protobuf:"bytes,1,opt,name=realm,proto3" json:"realm,omitempty"`
+	// BigQuery table to export test variants to.
+	//
+	// This table MUST also be listed in the realm configuration.
+	CloudProject string `protobuf:"bytes,2,opt,name=cloud_project,json=cloudProject,proto3" json:"cloud_project,omitempty"`
+	Dataset      string `protobuf:"bytes,3,opt,name=dataset,proto3" json:"dataset,omitempty"`
+	Table        string `protobuf:"bytes,4,opt,name=table,proto3" json:"table,omitempty"`
+	// Time range of the data to be exported.
+	//
+	// Earliest and Latest should be full hours. I.e. they should be 0:00, 1:00 ...
+	// Otherwise they will be truncated to the full hours.
+	//
+	// Note that each row has a separate time range, which size is controlled by
+	// testvariantbqexporter. As of Nov 2021, each row contains 1 hour worth of data.
+	// If the time range spans longer than 1 hour, the RPC will shard this range
+	// into a list of smaller time_ranges each spans 1 hour and schedule
+	// ExportTestVariants tasks for each of the smaller ones.
+	TimeRange *v1.TimeRange `protobuf:"bytes,6,opt,name=time_range,json=timeRange,proto3" json:"time_range,omitempty"`
+}
+
+func (x *ExportTestVariantsRequest) Reset() {
+	*x = ExportTestVariantsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_admin_proto_admin_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ExportTestVariantsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExportTestVariantsRequest) ProtoMessage() {}
+
+func (x *ExportTestVariantsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_admin_proto_admin_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ExportTestVariantsRequest.ProtoReflect.Descriptor instead.
+func (*ExportTestVariantsRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ExportTestVariantsRequest) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *ExportTestVariantsRequest) GetCloudProject() string {
+	if x != nil {
+		return x.CloudProject
+	}
+	return ""
+}
+
+func (x *ExportTestVariantsRequest) GetDataset() string {
+	if x != nil {
+		return x.Dataset
+	}
+	return ""
+}
+
+func (x *ExportTestVariantsRequest) GetTable() string {
+	if x != nil {
+		return x.Table
+	}
+	return ""
+}
+
+func (x *ExportTestVariantsRequest) GetTimeRange() *v1.TimeRange {
+	if x != nil {
+		return x.TimeRange
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_internal_admin_proto_admin_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDesc = []byte{
+	0x0a, 0x38, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61,
+	0x64, 0x6d, 0x69, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x16, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x61, 0x64, 0x6d,
+	0x69, 0x6e, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
+	0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65,
+	0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76,
+	0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xbc,
+	0x01, 0x0a, 0x19, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05,
+	0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x61,
+	0x6c, 0x6d, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x5f, 0x70, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6c, 0x6f, 0x75, 0x64,
+	0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x61, 0x74, 0x61, 0x73,
+	0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x61, 0x74, 0x61, 0x73, 0x65,
+	0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x34, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x5f,
+	0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e,
+	0x67, 0x65, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x32, 0x6a, 0x0a,
+	0x05, 0x41, 0x64, 0x6d, 0x69, 0x6e, 0x12, 0x61, 0x0a, 0x12, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74,
+	0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x12, 0x31, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e,
+	0x61, 0x64, 0x6d, 0x69, 0x6e, 0x2e, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x54, 0x65, 0x73, 0x74,
+	0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+	0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x42, 0x36, 0x5a, 0x34, 0x69, 0x6e, 0x66,
+	0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x61, 0x64,
+	0x6d, 0x69, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x70,
+	0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescData = file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_internal_admin_proto_admin_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_internal_admin_proto_admin_proto_goTypes = []interface{}{
+	(*ExportTestVariantsRequest)(nil), // 0: weetbix.internal.admin.ExportTestVariantsRequest
+	(*v1.TimeRange)(nil),              // 1: weetbix.v1.TimeRange
+	(*emptypb.Empty)(nil),             // 2: google.protobuf.Empty
+}
+var file_infra_appengine_weetbix_internal_admin_proto_admin_proto_depIdxs = []int32{
+	1, // 0: weetbix.internal.admin.ExportTestVariantsRequest.time_range:type_name -> weetbix.v1.TimeRange
+	0, // 1: weetbix.internal.admin.Admin.ExportTestVariants:input_type -> weetbix.internal.admin.ExportTestVariantsRequest
+	2, // 2: weetbix.internal.admin.Admin.ExportTestVariants:output_type -> google.protobuf.Empty
+	2, // [2:3] is the sub-list for method output_type
+	1, // [1:2] is the sub-list for method input_type
+	1, // [1:1] is the sub-list for extension type_name
+	1, // [1:1] is the sub-list for extension extendee
+	0, // [0:1] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_internal_admin_proto_admin_proto_init() }
+func file_infra_appengine_weetbix_internal_admin_proto_admin_proto_init() {
+	if File_infra_appengine_weetbix_internal_admin_proto_admin_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_internal_admin_proto_admin_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ExportTestVariantsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_internal_admin_proto_admin_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_internal_admin_proto_admin_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_internal_admin_proto_admin_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_internal_admin_proto_admin_proto = out.File
+	file_infra_appengine_weetbix_internal_admin_proto_admin_proto_rawDesc = nil
+	file_infra_appengine_weetbix_internal_admin_proto_admin_proto_goTypes = nil
+	file_infra_appengine_weetbix_internal_admin_proto_admin_proto_depIdxs = nil
+}
diff --git a/analysis/internal/admin/proto/admin.proto b/analysis/internal/admin/proto/admin.proto
new file mode 100644
index 0000000..bd49a83
--- /dev/null
+++ b/analysis/internal/admin/proto/admin.proto
@@ -0,0 +1,65 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.internal.admin;
+
+import "google/protobuf/empty.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+
+option go_package = "go.chromium.org/luci/analysis/internal/admin/proto;adminpb";
+
+// Admin ops for Weetbix maintainers only.
+// You can call the APIs using RPC Explorer:
+// * https://chops-weetbix-dev.appspot.com/rpcexplorer/services/ for dev
+// * https://chops-weetbix.appspot.com/rpcexplorer/services/ for prod
+service Admin {
+  // ExportTestVariants triggers ExportTestVariants tasks to export test
+  // variants to the specified table.
+  //
+  // In common cases, bq export should be done automatically by cron job.
+  // This RPC is only used to back fill data that already in Weetbix Spanner to
+  // BigQuery. It doesn't work if the request asks for data before Weetbix
+  // result ingestion started. If such request arises, we need to add another
+  // Admin API for it.
+  rpc ExportTestVariants(ExportTestVariantsRequest) returns (google.protobuf.Empty) {};
+}
+
+message ExportTestVariantsRequest {
+  // LUCI Realm. Test variants in this realm are exported.
+  string realm = 1;
+
+  // BigQuery table to export test variants to.
+  //
+  // This table MUST also be listed in the realm configuration.
+  string cloud_project = 2;
+  string dataset = 3;
+  string table = 4;
+
+  // Note that the predicate field is omitted in this request. Because this
+  // RPC will use the predicate from realm configuration exclusively.
+
+  // Time range of the data to be exported.
+  //
+  // Earliest and Latest should be full hours. I.e. they should be 0:00, 1:00 ...
+  // Otherwise they will be truncated to the full hours.
+  //
+  // Note that each row has a separate time range, which size is controlled by
+  // testvariantbqexporter. As of Nov 2021, each row contains 1 hour worth of data.
+  // If the time range spans longer than 1 hour, the RPC will shard this range
+  // into a list of smaller time_ranges each spans 1 hour and schedule
+  // ExportTestVariants tasks for each of the smaller ones.
+  weetbix.v1.TimeRange time_range = 6;
+}
diff --git a/analysis/internal/admin/proto/admin_grpc.pb.go b/analysis/internal/admin/proto/admin_grpc.pb.go
new file mode 100644
index 0000000..b4e02c2
--- /dev/null
+++ b/analysis/internal/admin/proto/admin_grpc.pb.go
@@ -0,0 +1,118 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+
+package adminpb
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+// AdminClient is the client API for Admin service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type AdminClient interface {
+	// ExportTestVariants triggers ExportTestVariants tasks to export test
+	// variants to the specified table.
+	//
+	// In common cases, bq export should be done automatically by cron job.
+	// This RPC is only used to back fill data that already in Weetbix Spanner to
+	// BigQuery. It doesn't work if the request asks for data before Weetbix
+	// result ingestion started. If such request arises, we need to add another
+	// Admin API for it.
+	ExportTestVariants(ctx context.Context, in *ExportTestVariantsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+}
+
+type adminClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewAdminClient(cc grpc.ClientConnInterface) AdminClient {
+	return &adminClient{cc}
+}
+
+func (c *adminClient) ExportTestVariants(ctx context.Context, in *ExportTestVariantsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/weetbix.internal.admin.Admin/ExportTestVariants", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// AdminServer is the server API for Admin service.
+// All implementations must embed UnimplementedAdminServer
+// for forward compatibility
+type AdminServer interface {
+	// ExportTestVariants triggers ExportTestVariants tasks to export test
+	// variants to the specified table.
+	//
+	// In common cases, bq export should be done automatically by cron job.
+	// This RPC is only used to back fill data that already in Weetbix Spanner to
+	// BigQuery. It doesn't work if the request asks for data before Weetbix
+	// result ingestion started. If such request arises, we need to add another
+	// Admin API for it.
+	ExportTestVariants(context.Context, *ExportTestVariantsRequest) (*emptypb.Empty, error)
+	mustEmbedUnimplementedAdminServer()
+}
+
+// UnimplementedAdminServer must be embedded to have forward compatible implementations.
+type UnimplementedAdminServer struct {
+}
+
+func (UnimplementedAdminServer) ExportTestVariants(context.Context, *ExportTestVariantsRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ExportTestVariants not implemented")
+}
+func (UnimplementedAdminServer) mustEmbedUnimplementedAdminServer() {}
+
+// UnsafeAdminServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to AdminServer will
+// result in compilation errors.
+type UnsafeAdminServer interface {
+	mustEmbedUnimplementedAdminServer()
+}
+
+func RegisterAdminServer(s grpc.ServiceRegistrar, srv AdminServer) {
+	s.RegisterService(&Admin_ServiceDesc, srv)
+}
+
+func _Admin_ExportTestVariants_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ExportTestVariantsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(AdminServer).ExportTestVariants(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.internal.admin.Admin/ExportTestVariants",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(AdminServer).ExportTestVariants(ctx, req.(*ExportTestVariantsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// Admin_ServiceDesc is the grpc.ServiceDesc for Admin service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var Admin_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.internal.admin.Admin",
+	HandlerType: (*AdminServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "ExportTestVariants",
+			Handler:    _Admin_ExportTestVariants_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/admin/proto/admin.proto",
+}
diff --git a/analysis/internal/admin/proto/gen.go b/analysis/internal/admin/proto/gen.go
new file mode 100644
index 0000000..8f804dc
--- /dev/null
+++ b/analysis/internal/admin/proto/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package adminpb
+
+//go:generate cproto -use-grpc-plugin
diff --git a/analysis/internal/admin/proto/pb.discovery.go b/analysis/internal/admin/proto/pb.discovery.go
new file mode 100644
index 0000000..2a392b5
--- /dev/null
+++ b/analysis/internal/admin/proto/pb.discovery.go
@@ -0,0 +1,2171 @@
+// Code generated by cproto. DO NOT EDIT.
+
+package adminpb
+
+import "go.chromium.org/luci/grpc/discovery"
+
+import "google.golang.org/protobuf/types/descriptorpb"
+
+func init() {
+	discovery.RegisterDescriptorSetCompressed(
+		[]string{
+			"weetbix.internal.admin.Admin",
+		},
+		[]byte{31, 139,
+			8, 0, 0, 0, 0, 0, 0, 255, 236, 189, 107, 120, 100, 199,
+			117, 24, 136, 251, 106, 52, 10, 175, 70, 1, 36, 49, 61, 195,
+			153, 154, 158, 25, 226, 49, 141, 158, 225, 240, 37, 206, 136, 98,
+			48, 64, 115, 216, 20, 6, 24, 55, 48, 162, 40, 127, 34, 120,
+			209, 93, 13, 92, 206, 237, 123, 91, 247, 222, 30, 16, 140, 20,
+			51, 86, 100, 173, 21, 201, 43, 89, 150, 44, 71, 178, 172, 200,
+			86, 18, 42, 218, 48, 150, 189, 126, 100, 173, 149, 31, 241, 58,
+			182, 98, 37, 241, 58, 94, 199, 202, 218, 94, 217, 146, 21, 107,
+			35, 203, 162, 31, 178, 44, 217, 251, 157, 83, 143, 123, 187, 129,
+			33, 71, 15, 58, 63, 86, 243, 205, 247, 161, 79, 221, 186, 231,
+			86, 157, 58, 117, 234, 156, 83, 167, 78, 145, 143, 83, 114, 120,
+			59, 12, 183, 125, 126, 166, 19, 133, 73, 184, 213, 109, 157, 225,
+			237, 78, 178, 87, 65, 144, 142, 139, 135, 21, 245, 176, 52, 72,
+			156, 42, 60, 191, 248, 6, 50, 217, 8, 219, 149, 190, 231, 23,
+			9, 62, 189, 2, 224, 21, 227, 53, 234, 241, 118, 232, 187, 193,
+			118, 37, 140, 182, 211, 207, 36, 123, 29, 30, 159, 185, 22, 132,
+			187, 129, 248, 100, 103, 235, 203, 134, 241, 65, 211, 186, 116, 229,
+			226, 135, 205, 163, 151, 196, 155, 87, 100, 245, 202, 163, 220, 247,
+			95, 9, 149, 55, 224, 189, 71, 254, 166, 64, 114, 212, 62, 58,
+			112, 87, 129, 252, 251, 17, 98, 140, 80, 235, 232, 0, 61, 247,
+			11, 35, 12, 95, 104, 132, 62, 187, 216, 109, 181, 120, 20, 179,
+			5, 38, 80, 205, 196, 172, 233, 38, 46, 243, 130, 132, 71, 141,
+			29, 55, 216, 230, 172, 21, 70, 109, 55, 33, 108, 41, 236, 236,
+			69, 222, 246, 78, 194, 206, 157, 61, 251, 50, 249, 2, 171, 5,
+			141, 10, 99, 139, 190, 207, 240, 89, 204, 34, 30, 243, 232, 58,
+			111, 86, 8, 219, 73, 146, 78, 124, 254, 204, 153, 38, 191, 206,
+			253, 176, 195, 163, 88, 245, 181, 17, 182, 69, 39, 27, 161, 191,
+			176, 37, 26, 113, 134, 16, 86, 231, 77, 47, 78, 34, 111, 171,
+			155, 120, 97, 192, 220, 160, 201, 186, 49, 103, 94, 192, 226, 176,
+			27, 53, 56, 150, 108, 121, 129, 27, 237, 97, 187, 226, 50, 219,
+			245, 146, 29, 22, 70, 248, 55, 236, 38, 132, 181, 195, 166, 215,
+			242, 26, 46, 96, 40, 51, 55, 226, 172, 195, 163, 182, 151, 36,
+			188, 201, 58, 81, 120, 221, 107, 242, 38, 75, 118, 220, 132, 37,
+			59, 208, 59, 223, 15, 119, 189, 96, 155, 53, 194, 160, 233, 193,
+			75, 49, 188, 68, 88, 155, 39, 231, 9, 97, 240, 111, 190, 175,
+			97, 49, 11, 91, 170, 69, 141, 176, 201, 89, 187, 27, 39, 44,
+			226, 137, 235, 5, 136, 213, 221, 10, 175, 195, 35, 73, 49, 194,
+			130, 48, 241, 26, 188, 204, 146, 29, 47, 102, 190, 23, 39, 128,
+			33, 251, 197, 160, 217, 215, 156, 166, 23, 55, 124, 215, 107, 243,
+			168, 114, 163, 70, 120, 65, 150, 22, 170, 17, 157, 40, 108, 118,
+			27, 60, 109, 7, 73, 27, 242, 77, 181, 131, 48, 217, 187, 102,
+			216, 232, 182, 121, 144, 184, 106, 144, 206, 132, 17, 11, 147, 29,
+			30, 177, 182, 155, 240, 200, 115, 253, 56, 37, 53, 14, 80, 178,
+			195, 9, 203, 182, 94, 119, 106, 149, 123, 248, 38, 32, 14, 220,
+			54, 135, 6, 101, 121, 43, 8, 211, 103, 72, 119, 47, 137, 161,
+			71, 129, 64, 21, 70, 49, 107, 187, 123, 108, 139, 3, 167, 52,
+			89, 18, 50, 30, 52, 195, 40, 230, 192, 20, 157, 40, 108, 135,
+			9, 103, 130, 38, 73, 204, 154, 60, 242, 174, 243, 38, 107, 69,
+			97, 155, 8, 42, 196, 97, 43, 217, 5, 54, 145, 28, 196, 226,
+			14, 111, 0, 7, 177, 78, 228, 1, 99, 69, 192, 59, 129, 224,
+			162, 56, 198, 182, 19, 182, 241, 112, 109, 157, 173, 175, 61, 180,
+			241, 232, 98, 189, 202, 106, 235, 236, 74, 125, 237, 85, 181, 229,
+			234, 50, 187, 248, 24, 219, 120, 184, 202, 150, 214, 174, 60, 86,
+			175, 93, 122, 120, 131, 61, 188, 182, 178, 92, 173, 175, 179, 197,
+			213, 101, 182, 180, 182, 186, 81, 175, 93, 188, 186, 177, 86, 95,
+			39, 172, 180, 184, 206, 106, 235, 37, 124, 178, 184, 250, 24, 171,
+			190, 250, 74, 189, 186, 190, 206, 214, 234, 172, 118, 249, 202, 74,
+			173, 186, 204, 30, 93, 172, 215, 23, 87, 55, 106, 213, 245, 50,
+			171, 173, 46, 173, 92, 93, 174, 173, 94, 42, 179, 139, 87, 55,
+			216, 234, 218, 6, 97, 43, 181, 203, 181, 141, 234, 50, 219, 88,
+			43, 227, 103, 247, 191, 199, 214, 30, 98, 151, 171, 245, 165, 135,
+			23, 87, 55, 22, 47, 214, 86, 106, 27, 143, 225, 7, 31, 170,
+			109, 172, 194, 199, 30, 90, 171, 19, 182, 200, 174, 44, 214, 55,
+			106, 75, 87, 87, 22, 235, 236, 202, 213, 250, 149, 181, 245, 42,
+			131, 158, 45, 215, 214, 151, 86, 22, 107, 151, 171, 203, 21, 86,
+			91, 101, 171, 107, 172, 250, 170, 234, 234, 6, 91, 127, 120, 113,
+			101, 165, 183, 163, 132, 173, 61, 186, 90, 173, 67, 235, 179, 221,
+			100, 23, 171, 108, 165, 182, 120, 113, 165, 10, 159, 194, 126, 46,
+			215, 234, 213, 165, 13, 232, 80, 250, 107, 169, 182, 92, 93, 221,
+			88, 92, 41, 19, 182, 126, 165, 186, 84, 91, 92, 41, 179, 234,
+			171, 171, 151, 175, 172, 44, 214, 31, 43, 75, 164, 235, 213, 239,
+			184, 90, 93, 221, 168, 45, 174, 176, 229, 197, 203, 139, 151, 170,
+			235, 108, 246, 197, 168, 114, 165, 190, 182, 116, 181, 94, 189, 12,
+			173, 94, 123, 136, 173, 95, 189, 184, 190, 81, 219, 184, 186, 81,
+			101, 151, 214, 214, 150, 145, 216, 235, 213, 250, 171, 106, 75, 213,
+			245, 11, 108, 101, 109, 29, 9, 118, 117, 189, 90, 38, 108, 121,
+			113, 99, 17, 63, 125, 165, 190, 246, 80, 109, 99, 253, 2, 252,
+			190, 120, 117, 189, 134, 132, 171, 173, 110, 84, 235, 245, 171, 87,
+			54, 106, 107, 171, 115, 236, 225, 181, 71, 171, 175, 170, 214, 217,
+			210, 226, 213, 245, 234, 50, 82, 120, 109, 21, 122, 11, 188, 82,
+			93, 171, 63, 6, 104, 129, 14, 56, 2, 101, 246, 232, 195, 213,
+			141, 135, 171, 117, 32, 42, 82, 107, 17, 200, 176, 190, 81, 175,
+			45, 109, 100, 171, 173, 213, 217, 198, 90, 125, 131, 100, 250, 201,
+			86, 171, 151, 86, 106, 151, 170, 171, 75, 85, 120, 188, 6, 104,
+			30, 173, 173, 87, 231, 216, 98, 189, 182, 14, 21, 106, 248, 97,
+			246, 232, 226, 99, 108, 237, 42, 246, 26, 6, 234, 234, 122, 149,
+			136, 223, 25, 214, 45, 227, 120, 178, 218, 67, 108, 113, 249, 85,
+			53, 104, 185, 172, 125, 101, 109, 125, 189, 38, 217, 5, 201, 182,
+			244, 176, 164, 121, 133, 144, 60, 49, 76, 106, 177, 129, 105, 248,
+			149, 167, 86, 105, 224, 2, 25, 34, 102, 254, 148, 248, 41, 10,
+			79, 12, 84, 177, 112, 88, 252, 20, 133, 39, 7, 202, 88, 104,
+			136, 159, 162, 240, 212, 192, 105, 44, 148, 63, 69, 225, 29, 3,
+			37, 44, 36, 226, 167, 40, 156, 25, 56, 142, 133, 39, 197, 79,
+			81, 56, 59, 112, 12, 11, 143, 137, 159, 127, 109, 18, 211, 30,
+			160, 214, 93, 3, 133, 226, 159, 152, 108, 145, 109, 243, 128, 71,
+			94, 131, 225, 10, 202, 218, 60, 142, 221, 109, 46, 150, 128, 189,
+			176, 203, 26, 110, 192, 34, 190, 0, 11, 77, 18, 50, 247, 122,
+			232, 53, 89, 147, 183, 188, 0, 197, 95, 183, 227, 195, 98, 194,
+			155, 164, 247, 125, 20, 191, 123, 97, 55, 98, 139, 87, 106, 113,
+			133, 45, 178, 100, 175, 227, 53, 92, 159, 241, 167, 220, 118, 199,
+			231, 204, 139, 1, 31, 174, 95, 9, 115, 99, 148, 98, 17, 127,
+			93, 151, 199, 9, 97, 82, 170, 69, 60, 238, 132, 1, 124, 121,
+			175, 131, 162, 207, 13, 0, 31, 44, 62, 59, 97, 179, 194, 30,
+			10, 35, 230, 5, 113, 226, 6, 13, 174, 86, 35, 88, 95, 189,
+			6, 103, 15, 133, 33, 251, 251, 162, 136, 177, 168, 211, 96, 23,
+			221, 104, 182, 79, 215, 168, 160, 170, 49, 7, 107, 83, 55, 10,
+			98, 118, 131, 231, 23, 4, 154, 55, 128, 96, 219, 225, 236, 145,
+			245, 181, 85, 92, 73, 120, 172, 197, 124, 43, 140, 216, 19, 88,
+			251, 9, 232, 153, 160, 5, 86, 12, 183, 158, 228, 141, 132, 61,
+			241, 247, 223, 240, 68, 133, 16, 66, 44, 123, 192, 160, 214, 93,
+			249, 209, 173, 28, 126, 230, 46, 242, 171, 111, 178, 8, 235, 87,
+			161, 154, 60, 110, 68, 94, 39, 9, 163, 27, 233, 81, 151, 201,
+			196, 67, 158, 207, 151, 117, 197, 117, 158, 208, 151, 17, 187, 229,
+			249, 124, 218, 96, 214, 236, 240, 185, 147, 253, 202, 85, 165, 247,
+			13, 212, 116, 234, 248, 70, 233, 15, 109, 50, 121, 192, 83, 74,
+			137, 13, 139, 203, 180, 193, 140, 217, 161, 58, 254, 166, 211, 100,
+			176, 227, 54, 174, 185, 219, 124, 218, 196, 98, 5, 210, 163, 132,
+			52, 121, 135, 7, 77, 30, 52, 246, 166, 45, 102, 205, 14, 213,
+			51, 37, 244, 52, 153, 232, 116, 183, 124, 175, 177, 153, 169, 70,
+			152, 53, 235, 212, 11, 226, 193, 114, 90, 121, 134, 140, 239, 114,
+			247, 90, 182, 234, 48, 86, 29, 131, 226, 76, 197, 37, 50, 34,
+			249, 110, 19, 56, 101, 218, 198, 222, 179, 125, 189, 239, 239, 249,
+			176, 124, 11, 20, 66, 186, 72, 134, 120, 208, 109, 11, 12, 206,
+			13, 232, 87, 13, 186, 237, 126, 44, 121, 120, 77, 162, 24, 148,
+			236, 55, 157, 67, 4, 51, 251, 16, 172, 139, 231, 253, 56, 212,
+			123, 116, 137, 12, 241, 167, 18, 30, 192, 74, 58, 61, 136, 72,
+			78, 29, 48, 138, 220, 111, 246, 163, 72, 223, 163, 247, 146, 193,
+			176, 131, 42, 203, 116, 158, 25, 179, 195, 231, 142, 28, 200, 8,
+			107, 162, 78, 93, 85, 166, 53, 82, 16, 122, 219, 38, 232, 109,
+			155, 94, 208, 10, 167, 135, 16, 193, 177, 253, 29, 193, 138, 75,
+			97, 147, 215, 130, 86, 88, 31, 139, 123, 96, 122, 43, 201, 197,
+			123, 65, 226, 62, 53, 61, 130, 28, 34, 161, 210, 207, 230, 200,
+			248, 205, 176, 216, 5, 226, 180, 160, 151, 211, 230, 215, 67, 3,
+			241, 78, 47, 17, 115, 223, 32, 17, 23, 201, 112, 192, 227, 132,
+			55, 5, 71, 88, 55, 201, 83, 68, 188, 180, 159, 165, 236, 111,
+			136, 165, 94, 77, 198, 117, 147, 54, 35, 48, 57, 36, 111, 158,
+			121, 177, 150, 84, 170, 234, 189, 58, 188, 86, 31, 227, 61, 48,
+			93, 38, 36, 12, 120, 216, 218, 108, 242, 134, 63, 157, 191, 1,
+			149, 214, 160, 202, 62, 42, 133, 162, 180, 225, 211, 251, 83, 86,
+			27, 188, 1, 167, 92, 22, 147, 108, 31, 183, 93, 37, 99, 202,
+			28, 146, 61, 27, 194, 70, 84, 94, 180, 103, 117, 249, 154, 232,
+			216, 104, 148, 5, 233, 9, 162, 11, 54, 145, 173, 8, 74, 161,
+			17, 85, 184, 234, 182, 121, 241, 105, 50, 214, 75, 30, 58, 69,
+			156, 56, 113, 163, 4, 185, 208, 169, 11, 128, 22, 136, 197, 131,
+			38, 74, 57, 167, 14, 63, 233, 223, 75, 59, 108, 97, 135, 239,
+			216, 63, 162, 61, 152, 251, 251, 93, 188, 143, 140, 246, 116, 224,
+			102, 63, 93, 122, 61, 185, 229, 64, 212, 244, 213, 100, 170, 27,
+			160, 85, 218, 137, 56, 112, 172, 248, 212, 244, 31, 13, 222, 128,
+			231, 174, 102, 107, 11, 44, 245, 201, 238, 254, 194, 249, 161, 252,
+			231, 6, 11, 207, 60, 243, 204, 51, 102, 233, 23, 114, 100, 234,
+			160, 57, 115, 224, 244, 189, 149, 228, 130, 110, 123, 139, 71, 72,
+			36, 167, 46, 33, 186, 72, 28, 223, 221, 226, 254, 180, 205, 140,
+			217, 177, 115, 167, 111, 106, 86, 86, 86, 224, 149, 186, 120, 147,
+			190, 130, 216, 82, 68, 3, 134, 249, 155, 195, 0, 115, 169, 142,
+			239, 209, 195, 100, 8, 254, 10, 222, 200, 97, 155, 243, 80, 0,
+			124, 65, 139, 36, 143, 211, 164, 201, 213, 210, 166, 97, 96, 172,
+			38, 111, 185, 93, 63, 217, 188, 238, 250, 93, 142, 12, 63, 84,
+			31, 145, 133, 175, 130, 50, 122, 140, 12, 139, 89, 229, 5, 77,
+			254, 20, 74, 79, 167, 46, 38, 90, 13, 74, 224, 243, 79, 198,
+			97, 160, 88, 19, 63, 1, 5, 248, 249, 251, 250, 5, 247, 237,
+			7, 119, 111, 223, 92, 154, 33, 227, 66, 155, 144, 67, 239, 250,
+			211, 19, 204, 152, 205, 215, 199, 68, 241, 154, 44, 45, 253, 148,
+			73, 108, 20, 44, 227, 100, 120, 227, 177, 43, 213, 205, 229, 181,
+			171, 23, 87, 170, 5, 131, 142, 17, 130, 5, 15, 173, 172, 45,
+			110, 20, 76, 13, 215, 86, 55, 238, 189, 187, 96, 233, 23, 174,
+			138, 2, 59, 91, 225, 174, 115, 5, 135, 22, 200, 136, 64, 80,
+			123, 117, 117, 249, 222, 187, 11, 185, 222, 146, 187, 206, 21, 6,
+			233, 40, 25, 194, 146, 139, 107, 107, 43, 133, 188, 198, 9, 154,
+			253, 234, 165, 194, 144, 198, 121, 169, 190, 118, 245, 74, 129, 104,
+			12, 151, 171, 235, 235, 139, 151, 170, 133, 97, 93, 227, 226, 99,
+			27, 213, 245, 194, 72, 79, 179, 238, 58, 87, 24, 213, 159, 168,
+			174, 94, 189, 92, 24, 163, 19, 100, 84, 124, 66, 53, 98, 188,
+			175, 232, 222, 187, 11, 133, 180, 33, 2, 203, 68, 79, 193, 189,
+			119, 23, 104, 105, 137, 56, 200, 134, 148, 146, 177, 149, 197, 139,
+			213, 149, 205, 53, 180, 109, 22, 87, 10, 70, 90, 86, 175, 126,
+			199, 213, 90, 189, 186, 92, 48, 179, 101, 87, 170, 139, 27, 213,
+			229, 130, 85, 106, 144, 169, 131, 4, 234, 129, 83, 40, 195, 11,
+			230, 13, 120, 1, 113, 245, 243, 66, 233, 15, 76, 50, 121, 192,
+			162, 114, 224, 71, 30, 36, 142, 224, 101, 177, 204, 206, 29, 184,
+			58, 33, 103, 239, 91, 106, 241, 189, 172, 170, 97, 221, 64, 213,
+			0, 20, 251, 24, 246, 181, 251, 132, 191, 88, 31, 239, 189, 153,
+			245, 17, 203, 190, 190, 69, 192, 57, 96, 17, 184, 64, 38, 246,
+			33, 186, 105, 97, 252, 70, 131, 76, 223, 136, 56, 47, 34, 18,
+			205, 30, 145, 120, 161, 159, 130, 199, 111, 60, 8, 251, 198, 250,
+			159, 27, 228, 214, 131, 85, 202, 3, 219, 240, 10, 146, 19, 182,
+			147, 28, 239, 253, 107, 215, 101, 124, 220, 63, 216, 242, 173, 236,
+			106, 111, 221, 72, 47, 20, 173, 217, 215, 210, 239, 53, 201, 45,
+			7, 34, 63, 176, 161, 183, 19, 226, 5, 157, 110, 34, 84, 39,
+			33, 137, 135, 176, 4, 133, 23, 72, 217, 110, 162, 159, 91, 248,
+			156, 136, 34, 172, 240, 178, 180, 161, 54, 54, 244, 232, 13, 122,
+			186, 143, 49, 207, 146, 66, 195, 247, 120, 144, 108, 198, 73, 196,
+			221, 182, 23, 108, 227, 82, 147, 63, 239, 180, 92, 63, 230, 245,
+			113, 241, 120, 93, 61, 133, 55, 144, 129, 162, 204, 27, 185, 158,
+			55, 196, 99, 253, 70, 233, 29, 67, 100, 56, 163, 128, 211, 227,
+			100, 228, 73, 247, 186, 187, 169, 140, 42, 65, 137, 97, 40, 187,
+			34, 13, 171, 179, 100, 10, 171, 132, 221, 132, 71, 155, 13, 223,
+			141, 99, 36, 90, 30, 171, 82, 120, 182, 6, 143, 150, 212, 19,
+			122, 15, 153, 196, 55, 218, 93, 63, 241, 58, 62, 223, 4, 51,
+			47, 198, 37, 71, 183, 108, 2, 106, 92, 150, 21, 160, 69, 49,
+			93, 38, 183, 227, 107, 232, 22, 112, 19, 190, 201, 95, 215, 117,
+			253, 120, 211, 13, 154, 155, 59, 110, 188, 51, 61, 5, 8, 46,
+			154, 211, 70, 253, 16, 84, 188, 36, 235, 85, 177, 218, 98, 208,
+			124, 216, 141, 119, 232, 121, 114, 43, 98, 137, 147, 200, 11, 182,
+			55, 27, 59, 188, 113, 109, 179, 155, 180, 94, 54, 125, 56, 251,
+			125, 108, 225, 58, 214, 89, 130, 42, 87, 147, 214, 203, 232, 58,
+			25, 129, 193, 104, 123, 79, 243, 205, 86, 24, 225, 26, 58, 118,
+			128, 104, 202, 80, 176, 178, 38, 95, 184, 28, 54, 249, 121, 103,
+			253, 74, 181, 186, 92, 31, 86, 88, 30, 10, 35, 96, 168, 237,
+			80, 19, 120, 88, 48, 212, 118, 168, 200, 123, 15, 153, 108, 52,
+			54, 165, 43, 100, 83, 26, 99, 241, 116, 161, 135, 88, 141, 198,
+			37, 81, 65, 242, 120, 76, 239, 39, 183, 164, 196, 202, 190, 56,
+			177, 175, 151, 253, 175, 222, 67, 38, 59, 123, 251, 95, 164, 61,
+			95, 236, 236, 245, 191, 118, 31, 153, 234, 236, 116, 246, 191, 55,
+			159, 125, 143, 118, 118, 58, 253, 47, 158, 66, 203, 60, 226, 232,
+			173, 153, 190, 45, 91, 61, 243, 128, 86, 72, 161, 209, 216, 228,
+			129, 187, 229, 243, 77, 55, 226, 129, 27, 79, 31, 195, 202, 118,
+			18, 117, 121, 125, 172, 209, 168, 226, 195, 69, 124, 70, 231, 201,
+			68, 184, 245, 100, 67, 112, 228, 102, 39, 226, 45, 239, 169, 233,
+			147, 72, 222, 113, 120, 128, 252, 120, 5, 139, 233, 28, 41, 52,
+			226, 29, 55, 234, 160, 72, 142, 59, 110, 131, 79, 159, 18, 85,
+			69, 249, 170, 42, 134, 25, 17, 239, 122, 173, 68, 97, 156, 17,
+			51, 2, 203, 36, 182, 89, 82, 0, 74, 244, 124, 120, 22, 171,
+			141, 117, 118, 58, 217, 239, 158, 32, 163, 80, 51, 253, 232, 156,
+			80, 220, 58, 59, 153, 47, 222, 77, 110, 133, 74, 109, 158, 184,
+			77, 55, 113, 51, 181, 203, 88, 27, 200, 126, 89, 62, 236, 105,
+			103, 212, 221, 218, 211, 140, 181, 32, 218, 9, 101, 138, 181, 94,
+			50, 229, 188, 116, 158, 140, 100, 249, 158, 14, 17, 193, 249, 5,
+			3, 148, 160, 165, 181, 101, 80, 95, 94, 83, 45, 152, 160, 70,
+			173, 212, 54, 170, 155, 245, 171, 171, 27, 181, 203, 213, 130, 149,
+			81, 236, 31, 177, 243, 119, 20, 102, 64, 107, 24, 235, 181, 212,
+			232, 203, 201, 109, 202, 173, 18, 243, 100, 115, 215, 139, 112, 66,
+			182, 93, 177, 56, 106, 254, 153, 146, 181, 214, 121, 242, 168, 23,
+			193, 116, 107, 187, 9, 93, 33, 199, 130, 112, 51, 78, 220, 160,
+			233, 70, 205, 205, 212, 161, 181, 233, 54, 26, 60, 142, 67, 177,
+			16, 106, 44, 71, 130, 112, 93, 86, 78, 87, 136, 69, 89, 181,
+			143, 125, 173, 27, 177, 239, 97, 50, 212, 118, 59, 155, 60, 72,
+			162, 61, 212, 207, 243, 245, 124, 219, 237, 84, 1, 254, 59, 49,
+			147, 30, 177, 243, 118, 193, 121, 196, 206, 59, 133, 220, 35, 118,
+			62, 87, 24, 124, 196, 206, 231, 11, 67, 143, 216, 249, 161, 2,
+			41, 125, 218, 34, 35, 89, 13, 30, 12, 162, 6, 174, 97, 6,
+			74, 185, 19, 47, 168, 239, 87, 150, 96, 113, 59, 159, 19, 234,
+			114, 93, 188, 9, 138, 5, 176, 31, 23, 234, 73, 190, 46, 33,
+			122, 137, 228, 158, 140, 17, 119, 14, 113, 31, 228, 13, 204, 224,
+			126, 100, 29, 145, 15, 61, 178, 190, 185, 186, 86, 191, 188, 184,
+			82, 151, 175, 211, 67, 196, 246, 221, 167, 247, 122, 151, 65, 44,
+			186, 217, 97, 57, 68, 236, 93, 238, 94, 235, 93, 124, 176, 232,
+			37, 156, 30, 103, 136, 131, 244, 162, 132, 72, 138, 21, 6, 104,
+			158, 216, 75, 107, 117, 152, 34, 5, 50, 34, 74, 55, 175, 212,
+			170, 75, 213, 130, 89, 186, 135, 228, 4, 17, 96, 250, 104, 50,
+			20, 6, 36, 40, 113, 24, 234, 233, 213, 203, 23, 171, 245, 130,
+			185, 111, 240, 75, 49, 25, 201, 106, 230, 127, 55, 230, 249, 207,
+			27, 100, 56, 163, 105, 131, 138, 228, 250, 126, 184, 187, 233, 250,
+			158, 27, 75, 214, 32, 88, 180, 8, 37, 55, 59, 116, 127, 71,
+			147, 198, 41, 228, 74, 239, 55, 72, 161, 95, 213, 237, 107, 166,
+			241, 63, 178, 153, 165, 247, 25, 100, 172, 87, 191, 237, 107, 222,
+			241, 255, 161, 205, 251, 125, 147, 140, 246, 104, 181, 55, 219, 186,
+			215, 145, 9, 175, 201, 219, 157, 48, 225, 65, 99, 111, 211, 231,
+			215, 185, 63, 93, 66, 161, 177, 223, 205, 216, 243, 133, 74, 45,
+			125, 111, 5, 94, 59, 63, 89, 91, 174, 94, 190, 178, 182, 81,
+			93, 93, 122, 108, 243, 234, 234, 43, 87, 215, 30, 93, 173, 23,
+			188, 190, 106, 47, 225, 180, 191, 66, 10, 253, 141, 162, 183, 145,
+			131, 154, 85, 24, 160, 147, 100, 124, 117, 109, 115, 189, 182, 92,
+			221, 172, 62, 244, 80, 117, 105, 99, 93, 120, 66, 116, 237, 141,
+			158, 9, 94, 250, 39, 22, 153, 60, 160, 37, 116, 81, 218, 48,
+			194, 172, 90, 184, 153, 214, 87, 64, 139, 184, 226, 70, 137, 52,
+			121, 230, 8, 80, 41, 72, 188, 150, 199, 35, 233, 97, 18, 134,
+			205, 120, 90, 46, 156, 76, 101, 66, 59, 97, 236, 37, 222, 117,
+			190, 233, 5, 202, 29, 5, 134, 142, 93, 47, 168, 39, 181, 32,
+			209, 181, 3, 190, 237, 246, 213, 6, 97, 110, 213, 11, 234, 137,
+			174, 125, 156, 140, 52, 195, 46, 104, 127, 162, 30, 172, 29, 70,
+			125, 88, 148, 233, 42, 82, 175, 79, 253, 96, 35, 245, 97, 81,
+			38, 170, 204, 144, 113, 119, 123, 59, 2, 228, 10, 145, 176, 84,
+			198, 116, 49, 86, 44, 62, 66, 242, 138, 14, 176, 120, 3, 37,
+			54, 59, 194, 252, 54, 103, 135, 234, 249, 64, 61, 60, 78, 70,
+			188, 120, 51, 117, 235, 155, 204, 156, 205, 215, 135, 189, 88, 187,
+			68, 75, 255, 220, 36, 99, 189, 219, 18, 116, 153, 228, 253, 80,
+			68, 187, 200, 61, 177, 217, 23, 217, 201, 168, 172, 200, 250, 117,
+			253, 102, 241, 147, 6, 201, 171, 98, 122, 43, 177, 59, 110, 178,
+			131, 232, 156, 139, 102, 193, 168, 35, 12, 229, 113, 199, 13, 144,
+			5, 100, 57, 192, 48, 174, 62, 119, 155, 104, 6, 133, 237, 54,
+			15, 146, 88, 141, 171, 44, 95, 146, 197, 244, 52, 153, 72, 34,
+			215, 243, 123, 234, 218, 88, 183, 160, 30, 232, 202, 231, 201, 33,
+			133, 183, 201, 19, 183, 177, 195, 155, 233, 75, 57, 116, 119, 220,
+			38, 43, 44, 203, 231, 234, 221, 210, 127, 50, 200, 132, 50, 220,
+			154, 154, 88, 151, 9, 113, 131, 32, 76, 178, 228, 218, 207, 202,
+			251, 222, 171, 44, 234, 151, 234, 25, 4, 197, 54, 33, 233, 147,
+			27, 146, 237, 24, 25, 150, 123, 78, 184, 113, 41, 76, 125, 34,
+			138, 192, 194, 163, 83, 196, 217, 226, 219, 94, 32, 61, 201, 2,
+			80, 14, 25, 91, 59, 100, 46, 254, 131, 131, 195, 201, 10, 125,
+			238, 134, 248, 97, 227, 53, 11, 47, 26, 84, 150, 106, 171, 61,
+			49, 101, 197, 254, 152, 178, 58, 111, 249, 188, 1, 29, 124, 228,
+			187, 127, 205, 36, 131, 212, 153, 25, 248, 190, 65, 131, 124, 120,
+			28, 35, 202, 102, 190, 29, 81, 246, 237, 136, 178, 111, 71, 148,
+			125, 59, 162, 236, 219, 17, 101, 223, 142, 40, 251, 198, 35, 202,
+			206, 125, 202, 100, 139, 221, 100, 39, 140, 206, 179, 107, 60, 72,
+			194, 224, 239, 165, 130, 157, 205, 190, 18, 139, 216, 171, 220, 168,
+			233, 206, 17, 198, 46, 186, 48, 51, 195, 128, 133, 145, 183, 237,
+			5, 174, 191, 127, 1, 106, 242, 216, 219, 14, 216, 214, 30, 97,
+			108, 221, 13, 158, 116, 247, 216, 165, 29, 222, 118, 119, 221, 164,
+			204, 30, 225, 173, 22, 91, 230, 46, 136, 243, 160, 41, 36, 77,
+			92, 145, 209, 79, 217, 0, 47, 156, 207, 176, 108, 51, 177, 94,
+			110, 9, 41, 40, 194, 195, 132, 128, 107, 133, 221, 160, 9, 117,
+			197, 138, 140, 181, 227, 10, 76, 128, 235, 174, 239, 53, 179, 197,
+			24, 106, 6, 40, 34, 55, 136, 125, 208, 48, 88, 211, 139, 120,
+			35, 241, 247, 48, 244, 140, 29, 16, 158, 68, 180, 20, 113, 131,
+			61, 41, 19, 189, 64, 44, 161, 32, 44, 103, 121, 101, 187, 162,
+			235, 68, 66, 29, 2, 145, 198, 188, 118, 39, 140, 146, 120, 78,
+			199, 235, 205, 233, 120, 189, 211, 3, 203, 42, 52, 15, 126, 138,
+			194, 114, 26, 154, 87, 214, 161, 121, 11, 3, 119, 170, 208, 60,
+			248, 41, 10, 43, 3, 247, 169, 112, 63, 248, 41, 10, 207, 164,
+			161, 121, 103, 116, 104, 222, 217, 52, 52, 15, 126, 138, 194, 187,
+			7, 142, 144, 239, 34, 102, 126, 8, 127, 22, 19, 214, 31, 10,
+			38, 22, 158, 45, 206, 148, 127, 187, 137, 225, 103, 113, 135, 243,
+			38, 219, 226, 13, 23, 150, 240, 72, 43, 38, 11, 91, 192, 14,
+			132, 185, 254, 118, 24, 121, 201, 78, 59, 102, 205, 48, 152, 73,
+			216, 110, 24, 93, 99, 205, 46, 40, 237, 108, 43, 12, 147, 56,
+			137, 220, 78, 199, 11, 182, 43, 132, 60, 137, 65, 130, 246, 203,
+			6, 206, 27, 197, 199, 113, 220, 149, 250, 192, 26, 97, 187, 227,
+			249, 60, 194, 225, 18, 91, 46, 251, 198, 102, 157, 39, 184, 100,
+			184, 34, 76, 16, 152, 66, 180, 157, 8, 6, 96, 94, 194, 58,
+			110, 20, 3, 43, 232, 152, 184, 151, 229, 15, 145, 97, 98, 219,
+			3, 230, 0, 181, 238, 55, 103, 201, 8, 113, 0, 176, 1, 34,
+			10, 202, 81, 235, 254, 225, 163, 10, 50, 168, 117, 255, 177, 19,
+			10, 178, 168, 117, 255, 29, 51, 228, 12, 49, 109, 131, 218, 15,
+			12, 188, 198, 40, 158, 96, 203, 146, 53, 99, 230, 98, 219, 125,
+			158, 240, 44, 219, 201, 22, 24, 6, 181, 30, 200, 31, 38, 247,
+			19, 219, 54, 160, 5, 175, 48, 15, 151, 202, 130, 49, 97, 45,
+			44, 179, 136, 251, 104, 42, 1, 51, 70, 97, 152, 100, 148, 146,
+			36, 226, 92, 180, 208, 192, 246, 190, 194, 212, 144, 67, 173, 87,
+			12, 79, 40, 200, 160, 214, 43, 232, 173, 10, 178, 168, 245, 138,
+			67, 69, 50, 143, 159, 52, 168, 245, 160, 121, 180, 116, 59, 67,
+			150, 45, 181, 194, 176, 84, 198, 63, 149, 45, 55, 42, 149, 25,
+			79, 26, 21, 133, 213, 176, 161, 178, 134, 28, 106, 61, 168, 191,
+			1, 29, 121, 144, 30, 82, 144, 69, 173, 7, 143, 220, 78, 238,
+			198, 111, 152, 212, 186, 104, 30, 47, 206, 176, 85, 181, 186, 203,
+			225, 192, 201, 0, 236, 179, 151, 78, 106, 253, 53, 211, 134, 215,
+			52, 228, 80, 235, 162, 254, 26, 52, 251, 34, 61, 162, 32, 139,
+			90, 23, 143, 49, 242, 29, 248, 53, 139, 90, 203, 230, 108, 113,
+			153, 97, 228, 131, 248, 30, 176, 130, 8, 244, 75, 63, 42, 219,
+			32, 149, 29, 29, 214, 39, 244, 37, 84, 169, 116, 83, 44, 27,
+			112, 106, 200, 161, 214, 242, 112, 65, 65, 6, 181, 150, 39, 74,
+			10, 130, 175, 159, 154, 33, 79, 99, 83, 108, 106, 93, 50, 239,
+			40, 182, 251, 155, 178, 203, 221, 107, 55, 215, 144, 10, 193, 112,
+			83, 161, 42, 45, 160, 194, 14, 146, 181, 237, 109, 71, 66, 212,
+			132, 129, 191, 87, 97, 203, 33, 232, 124, 160, 27, 233, 54, 219,
+			248, 113, 13, 57, 212, 186, 164, 219, 108, 27, 212, 186, 52, 193,
+			20, 100, 81, 235, 210, 137, 83, 228, 94, 108, 179, 67, 173, 71,
+			204, 114, 113, 14, 181, 253, 36, 236, 44, 160, 95, 166, 71, 186,
+			102, 101, 176, 254, 158, 99, 195, 139, 26, 202, 81, 235, 145, 225,
+			162, 130, 12, 106, 61, 114, 120, 70, 65, 22, 181, 30, 153, 63,
+			141, 179, 206, 48, 115, 212, 122, 165, 185, 32, 31, 229, 108, 128,
+			20, 146, 28, 60, 147, 179, 206, 48, 115, 6, 181, 94, 121, 108,
+			86, 65, 22, 181, 94, 121, 186, 44, 145, 12, 82, 107, 197, 172,
+			200, 71, 131, 54, 64, 10, 201, 96, 142, 90, 43, 195, 199, 21,
+			100, 80, 107, 165, 52, 167, 32, 139, 90, 43, 229, 5, 137, 36,
+			79, 173, 203, 26, 73, 222, 6, 72, 33, 201, 231, 168, 117, 121,
+			248, 152, 130, 12, 106, 93, 102, 10, 73, 222, 162, 214, 101, 141,
+			100, 136, 90, 107, 230, 9, 249, 104, 200, 6, 72, 33, 25, 202,
+			81, 107, 109, 88, 77, 195, 33, 131, 90, 107, 183, 169, 206, 13,
+			89, 212, 90, 59, 94, 34, 127, 102, 32, 22, 66, 173, 171, 230,
+			153, 226, 103, 13, 182, 33, 8, 205, 253, 166, 18, 109, 49, 83,
+			193, 54, 61, 107, 142, 187, 5, 107, 13, 176, 144, 94, 127, 51,
+			182, 75, 133, 176, 199, 194, 46, 234, 208, 177, 219, 226, 254, 30,
+			139, 120, 27, 172, 23, 28, 72, 30, 36, 94, 196, 229, 103, 212,
+			178, 181, 227, 70, 109, 16, 163, 81, 55, 72, 188, 54, 39, 172,
+			213, 13, 26, 226, 195, 94, 178, 167, 88, 57, 93, 38, 98, 182,
+			176, 128, 69, 217, 86, 121, 49, 11, 56, 111, 162, 98, 224, 239,
+			225, 202, 47, 237, 68, 48, 45, 88, 18, 134, 126, 172, 89, 136,
+			216, 208, 109, 13, 229, 168, 117, 117, 88, 73, 20, 98, 80, 235,
+			106, 113, 94, 65, 22, 181, 174, 46, 84, 200, 107, 145, 90, 195,
+			212, 122, 204, 60, 90, 188, 130, 43, 134, 136, 248, 212, 147, 62,
+			35, 112, 197, 227, 110, 71, 78, 59, 244, 15, 161, 21, 200, 74,
+			88, 237, 92, 9, 21, 15, 1, 220, 85, 210, 205, 26, 182, 1,
+			191, 134, 28, 106, 61, 166, 5, 209, 176, 65, 173, 199, 232, 180,
+			130, 44, 106, 61, 118, 248, 118, 50, 67, 76, 219, 164, 246, 107,
+			7, 222, 96, 20, 15, 247, 44, 5, 58, 208, 125, 175, 163, 150,
+			0, 144, 101, 175, 205, 223, 134, 252, 99, 194, 18, 240, 184, 121,
+			24, 241, 153, 40, 212, 31, 151, 95, 54, 81, 168, 63, 46, 191,
+			108, 162, 80, 127, 92, 10, 117, 19, 133, 250, 227, 135, 138, 18,
+			137, 65, 173, 39, 204, 121, 249, 8, 164, 246, 19, 26, 137, 145,
+			163, 214, 19, 146, 147, 77, 148, 218, 79, 176, 83, 10, 178, 168,
+			245, 196, 236, 156, 68, 98, 82, 203, 149, 211, 193, 68, 97, 236,
+			106, 36, 48, 105, 93, 141, 4, 62, 231, 202, 233, 96, 162, 48,
+			118, 229, 116, 64, 160, 97, 158, 150, 143, 64, 140, 54, 52, 18,
+			43, 71, 173, 134, 20, 17, 38, 138, 209, 198, 225, 59, 20, 4,
+			239, 205, 205, 75, 36, 54, 181, 154, 82, 68, 152, 40, 215, 154,
+			26, 137, 157, 163, 86, 83, 138, 8, 19, 229, 90, 83, 138, 8,
+			19, 229, 90, 243, 116, 153, 140, 0, 18, 107, 128, 218, 45, 243,
+			154, 37, 158, 89, 64, 189, 22, 153, 38, 135, 73, 14, 32, 32,
+			251, 182, 125, 123, 105, 4, 172, 81, 191, 27, 123, 40, 252, 199,
+			200, 160, 120, 104, 195, 211, 145, 20, 118, 168, 181, 61, 74, 83,
+			216, 160, 214, 246, 228, 116, 10, 91, 212, 218, 62, 124, 68, 35,
+			55, 168, 181, 99, 31, 46, 141, 176, 234, 83, 251, 145, 195, 240,
+			236, 100, 144, 195, 178, 186, 147, 65, 14, 67, 180, 51, 121, 107,
+			10, 91, 212, 218, 57, 84, 36, 163, 18, 185, 73, 173, 39, 237,
+			51, 250, 49, 16, 235, 201, 12, 58, 24, 170, 39, 71, 75, 41,
+			108, 80, 235, 201, 19, 243, 41, 108, 81, 235, 201, 133, 138, 164,
+			180, 67, 45, 95, 143, 57, 72, 116, 95, 83, 26, 36, 186, 47,
+			167, 163, 137, 18, 221, 47, 170, 49, 7, 137, 238, 235, 49, 207,
+			81, 43, 48, 207, 200, 71, 32, 209, 3, 141, 4, 36, 122, 160,
+			25, 7, 36, 122, 192, 20, 159, 130, 68, 15, 116, 75, 6, 169,
+			213, 49, 21, 59, 128, 68, 239, 104, 36, 32, 209, 59, 186, 37,
+			32, 209, 59, 197, 227, 10, 178, 168, 213, 57, 121, 138, 124, 216,
+			192, 65, 55, 168, 221, 53, 159, 178, 138, 239, 53, 24, 198, 89,
+			129, 88, 80, 174, 43, 150, 184, 219, 76, 68, 67, 197, 21, 86,
+			63, 160, 20, 197, 37, 172, 171, 202, 237, 0, 226, 11, 133, 100,
+			204, 194, 136, 105, 175, 48, 195, 80, 50, 189, 132, 199, 110, 91,
+			27, 44, 25, 196, 178, 82, 219, 221, 67, 71, 17, 11, 175, 243,
+			200, 119, 59, 82, 204, 152, 22, 12, 116, 151, 220, 38, 185, 6,
+			149, 193, 235, 55, 96, 73, 161, 238, 93, 215, 195, 44, 20, 190,
+			235, 154, 107, 132, 202, 119, 93, 179, 164, 80, 250, 174, 107, 150,
+			68, 181, 111, 247, 6, 44, 41, 244, 188, 221, 12, 114, 96, 201,
+			221, 12, 114, 104, 233, 174, 102, 73, 161, 237, 237, 106, 225, 147,
+			167, 214, 158, 89, 150, 227, 1, 203, 232, 158, 30, 57, 88, 70,
+			247, 134, 167, 21, 100, 80, 107, 239, 208, 140, 130, 44, 106, 237,
+			205, 159, 38, 223, 141, 35, 7, 235, 232, 235, 205, 83, 197, 110,
+			74, 63, 177, 58, 161, 131, 168, 204, 118, 119, 188, 198, 206, 1,
+			227, 163, 134, 231, 160, 161, 0, 243, 111, 219, 187, 206, 3, 225,
+			129, 130, 151, 197, 162, 196, 83, 158, 8, 131, 134, 82, 105, 76,
+			92, 190, 95, 175, 27, 63, 228, 80, 235, 245, 90, 252, 194, 242,
+			253, 122, 170, 56, 25, 150, 239, 215, 151, 78, 146, 97, 2, 82,
+			199, 249, 174, 129, 239, 49, 12, 20, 238, 32, 214, 190, 43, 127,
+			59, 89, 35, 182, 109, 153, 3, 212, 254, 135, 134, 121, 190, 184,
+			40, 108, 27, 48, 69, 34, 22, 39, 97, 196, 213, 162, 142, 54,
+			74, 51, 228, 49, 152, 74, 17, 111, 132, 219, 129, 247, 52, 103,
+			59, 60, 226, 21, 182, 206, 185, 86, 76, 71, 137, 3, 8, 109,
+			196, 168, 193, 28, 128, 195, 71, 21, 104, 0, 120, 236, 46, 5,
+			90, 0, 222, 123, 63, 121, 13, 52, 204, 161, 246, 155, 12, 243,
+			80, 241, 50, 91, 194, 200, 178, 24, 45, 43, 84, 243, 56, 107,
+			116, 227, 36, 108, 167, 109, 10, 82, 102, 151, 74, 172, 23, 167,
+			44, 158, 109, 23, 8, 91, 203, 25, 0, 228, 163, 211, 226, 195,
+			14, 180, 227, 77, 198, 232, 132, 2, 77, 0, 111, 153, 38, 119,
+			17, 144, 230, 185, 239, 53, 6, 190, 96, 24, 197, 83, 61, 11,
+			101, 170, 139, 120, 65, 186, 110, 86, 8, 25, 38, 150, 13, 118,
+			214, 247, 26, 249, 35, 100, 140, 216, 182, 109, 15, 208, 220, 91,
+			12, 243, 89, 195, 194, 15, 216, 96, 214, 217, 111, 49, 6, 135,
+			201, 58, 201, 217, 194, 178, 179, 223, 102, 216, 83, 197, 37, 118,
+			22, 52, 18, 61, 218, 96, 192, 242, 40, 10, 163, 184, 66, 216,
+			90, 212, 4, 51, 62, 102, 187, 220, 139, 196, 179, 29, 15, 6,
+			7, 15, 144, 69, 220, 141, 195, 0, 116, 149, 113, 50, 40, 144,
+			26, 136, 117, 60, 45, 48, 161, 128, 78, 146, 49, 249, 89, 131,
+			218, 223, 103, 216, 147, 186, 130, 33, 10, 198, 210, 2, 19, 10,
+			38, 40, 217, 149, 111, 152, 212, 126, 135, 97, 79, 22, 183, 217,
+			106, 152, 176, 215, 120, 219, 175, 113, 183, 25, 15, 64, 131, 107,
+			86, 24, 91, 149, 219, 102, 90, 64, 37, 238, 53, 206, 238, 60,
+			203, 182, 246, 18, 30, 87, 24, 187, 26, 115, 150, 9, 32, 102,
+			94, 139, 48, 181, 215, 150, 85, 120, 124, 239, 26, 247, 247, 50,
+			157, 129, 182, 190, 35, 219, 52, 209, 148, 9, 170, 59, 99, 81,
+			251, 251, 13, 123, 74, 87, 0, 217, 250, 253, 217, 238, 91, 38,
+			20, 208, 73, 221, 25, 155, 218, 239, 250, 150, 117, 230, 174, 115,
+			55, 223, 25, 96, 143, 119, 101, 59, 3, 202, 216, 187, 178, 157,
+			113, 168, 253, 110, 195, 190, 69, 87, 112, 12, 44, 40, 164, 5,
+			38, 20, 76, 78, 233, 55, 114, 212, 254, 193, 236, 27, 57, 3,
+			11, 210, 55, 114, 38, 20, 100, 222, 24, 164, 246, 123, 12, 155,
+			234, 10, 131, 6, 22, 140, 166, 5, 38, 20, 20, 38, 244, 27,
+			121, 106, 255, 80, 150, 196, 121, 3, 11, 82, 18, 231, 77, 40,
+			160, 147, 228, 211, 134, 124, 101, 136, 218, 239, 7, 206, 254, 79,
+			6, 219, 112, 183, 23, 154, 220, 247, 218, 30, 104, 183, 122, 195,
+			179, 66, 216, 165, 40, 236, 118, 196, 105, 70, 47, 102, 233, 14,
+			60, 170, 187, 32, 63, 83, 165, 216, 11, 132, 202, 124, 87, 133,
+			61, 28, 238, 242, 235, 60, 42, 11, 55, 222, 93, 4, 12, 86,
+			159, 235, 29, 129, 152, 197, 59, 97, 215, 111, 178, 56, 241, 124,
+			31, 132, 168, 187, 229, 163, 151, 2, 229, 26, 138, 223, 109, 252,
+			240, 46, 218, 22, 104, 18, 192, 23, 9, 75, 34, 238, 38, 242,
+			161, 148, 215, 110, 204, 186, 1, 230, 84, 144, 37, 153, 225, 28,
+			50, 176, 147, 233, 112, 14, 153, 80, 48, 49, 73, 22, 36, 21,
+			8, 181, 127, 196, 176, 111, 45, 221, 206, 86, 120, 176, 157, 236,
+			28, 76, 7, 253, 62, 49, 176, 126, 58, 118, 196, 132, 130, 201,
+			91, 200, 9, 137, 112, 152, 218, 31, 4, 178, 78, 178, 85, 190,
+			11, 68, 185, 206, 35, 92, 233, 207, 101, 208, 12, 27, 88, 43,
+			109, 215, 176, 9, 5, 19, 169, 0, 24, 161, 246, 143, 102, 153,
+			102, 196, 192, 130, 116, 64, 71, 76, 40, 160, 41, 211, 140, 82,
+			251, 199, 178, 34, 99, 212, 192, 130, 148, 105, 70, 77, 40, 40,
+			164, 172, 60, 70, 237, 15, 25, 246, 109, 186, 194, 152, 129, 5,
+			19, 105, 129, 9, 5, 83, 183, 234, 55, 198, 169, 253, 207, 178,
+			111, 140, 27, 88, 144, 190, 49, 110, 66, 193, 212, 173, 100, 70,
+			190, 81, 160, 246, 63, 55, 236, 91, 74, 183, 193, 156, 140, 123,
+			166, 178, 112, 220, 169, 55, 11, 6, 214, 76, 59, 88, 48, 161,
+			128, 78, 105, 84, 19, 212, 254, 23, 55, 133, 106, 194, 192, 154,
+			41, 170, 9, 19, 10, 144, 86, 32, 244, 13, 154, 251, 151, 134,
+			249, 175, 181, 208, 7, 225, 250, 47, 141, 193, 17, 50, 143, 95,
+			2, 253, 201, 254, 95, 12, 251, 182, 98, 241, 134, 66, 95, 125,
+			12, 213, 37, 168, 76, 211, 2, 19, 10, 110, 81, 68, 3, 133,
+			201, 254, 87, 41, 209, 80, 7, 130, 130, 244, 13, 144, 229, 255,
+			42, 251, 134, 73, 237, 231, 178, 111, 0, 138, 231, 178, 111, 136,
+			26, 183, 220, 138, 107, 166, 13, 237, 253, 168, 97, 30, 22, 221,
+			193, 149, 253, 163, 106, 101, 183, 65, 191, 179, 63, 106, 12, 79,
+			40, 208, 0, 144, 222, 170, 64, 11, 192, 67, 69, 137, 201, 160,
+			246, 79, 24, 230, 17, 249, 208, 176, 17, 84, 152, 12, 7, 192,
+			225, 130, 2, 177, 242, 196, 109, 10, 180, 0, 44, 30, 150, 152,
+			76, 106, 255, 100, 218, 38, 144, 232, 63, 153, 98, 2, 153, 248,
+			147, 41, 38, 248, 236, 79, 26, 19, 170, 77, 176, 96, 252, 36,
+			180, 233, 125, 6, 162, 178, 168, 253, 51, 160, 111, 188, 205, 96,
+			181, 22, 211, 103, 161, 96, 104, 98, 158, 200, 189, 202, 128, 243,
+			166, 210, 232, 98, 158, 84, 24, 212, 221, 10, 113, 107, 209, 147,
+			251, 150, 234, 77, 130, 194, 63, 125, 87, 251, 167, 3, 84, 243,
+			245, 105, 156, 50, 203, 158, 229, 1, 197, 61, 61, 235, 83, 81,
+			125, 177, 108, 108, 158, 6, 115, 0, 14, 143, 43, 208, 0, 176,
+			48, 165, 64, 236, 203, 109, 211, 228, 167, 77, 236, 154, 77, 237,
+			143, 27, 38, 43, 254, 11, 19, 157, 117, 202, 198, 135, 214, 242,
+			160, 219, 198, 38, 199, 178, 149, 94, 220, 179, 7, 10, 191, 209,
+			23, 128, 61, 85, 79, 8, 195, 243, 31, 177, 216, 85, 117, 217,
+			76, 101, 166, 12, 138, 161, 23, 179, 86, 215, 247, 247, 22, 94,
+			215, 117, 125, 175, 229, 225, 58, 186, 150, 236, 240, 104, 215, 139,
+			121, 153, 45, 157, 62, 189, 0, 171, 33, 139, 27, 97, 199, 11,
+			182, 9, 139, 186, 190, 92, 37, 213, 190, 105, 203, 147, 155, 191,
+			184, 30, 204, 122, 21, 94, 97, 45, 47, 138, 133, 239, 72, 28,
+			35, 21, 45, 86, 218, 23, 180, 155, 164, 189, 66, 162, 187, 81,
+			99, 135, 55, 161, 79, 60, 72, 235, 161, 78, 203, 131, 164, 204,
+			194, 128, 193, 146, 19, 138, 3, 245, 97, 152, 16, 166, 67, 155,
+			231, 52, 213, 109, 65, 57, 13, 58, 0, 106, 38, 135, 101, 252,
+			227, 6, 85, 220, 103, 91, 0, 30, 61, 70, 254, 1, 18, 221,
+			161, 246, 47, 25, 230, 177, 98, 7, 105, 158, 170, 167, 47, 76,
+			103, 182, 197, 189, 96, 155, 201, 211, 117, 64, 190, 26, 144, 149,
+			128, 120, 8, 253, 235, 98, 5, 76, 45, 7, 55, 8, 120, 132,
+			121, 2, 20, 215, 233, 182, 59, 54, 54, 64, 131, 216, 30, 221,
+			118, 80, 40, 126, 201, 160, 69, 5, 90, 0, 222, 126, 148, 252,
+			184, 224, 152, 28, 181, 127, 205, 48, 79, 22, 63, 40, 56, 38,
+			232, 182, 49, 25, 130, 100, 20, 237, 232, 235, 241, 230, 37, 252,
+			169, 164, 63, 1, 128, 236, 25, 42, 67, 210, 85, 188, 21, 134,
+			62, 119, 129, 14, 165, 36, 234, 242, 18, 48, 124, 9, 131, 239,
+			74, 178, 134, 136, 152, 234, 255, 142, 60, 79, 40, 62, 3, 79,
+			208, 38, 152, 133, 201, 200, 227, 134, 219, 17, 164, 113, 131, 61,
+			182, 235, 238, 205, 169, 143, 129, 170, 214, 135, 104, 73, 215, 23,
+			205, 18, 113, 35, 88, 147, 189, 226, 1, 118, 231, 185, 151, 33,
+			15, 201, 74, 21, 194, 54, 214, 150, 215, 102, 197, 6, 227, 220,
+			121, 177, 143, 184, 112, 239, 221, 82, 83, 124, 80, 17, 56, 103,
+			35, 205, 52, 232, 0, 168, 233, 13, 234, 216, 175, 25, 244, 152,
+			2, 45, 0, 75, 39, 200, 63, 18, 194, 103, 144, 218, 159, 52,
+			204, 227, 197, 235, 48, 203, 80, 100, 128, 33, 24, 75, 119, 101,
+			147, 163, 191, 208, 101, 120, 96, 82, 113, 64, 118, 39, 105, 175,
+			195, 103, 98, 150, 30, 92, 38, 194, 57, 207, 178, 238, 89, 79,
+			248, 247, 64, 151, 21, 227, 226, 38, 226, 13, 205, 50, 131, 54,
+			54, 67, 131, 14, 128, 90, 126, 130, 126, 248, 73, 99, 66, 137,
+			237, 65, 11, 192, 99, 140, 124, 69, 116, 33, 79, 237, 223, 132,
+			46, 124, 222, 16, 25, 30, 82, 206, 86, 45, 168, 160, 85, 137,
+			68, 151, 82, 21, 12, 226, 125, 59, 104, 21, 41, 105, 8, 136,
+			132, 136, 237, 184, 162, 170, 203, 74, 250, 132, 104, 73, 90, 126,
+			48, 141, 83, 252, 101, 217, 39, 124, 52, 19, 139, 47, 17, 182,
+			43, 213, 63, 16, 48, 149, 172, 52, 242, 146, 25, 208, 54, 155,
+			221, 134, 12, 197, 16, 49, 39, 128, 106, 38, 22, 237, 223, 218,
+			3, 50, 95, 231, 81, 130, 210, 202, 75, 64, 100, 52, 220, 54,
+			247, 151, 220, 56, 157, 107, 121, 27, 59, 175, 65, 7, 64, 61,
+			246, 160, 38, 255, 102, 42, 39, 242, 22, 128, 71, 153, 92, 194,
+			134, 168, 253, 91, 134, 121, 82, 62, 28, 178, 17, 84, 152, 134,
+			114, 0, 14, 171, 213, 15, 52, 205, 223, 50, 166, 21, 23, 13,
+			89, 0, 150, 78, 144, 159, 24, 66, 84, 132, 218, 255, 221, 48,
+			79, 21, 127, 108, 8, 73, 24, 117, 121, 42, 108, 92, 169, 55,
+			179, 146, 242, 206, 151, 42, 236, 81, 144, 142, 250, 137, 102, 19,
+			85, 3, 72, 4, 218, 176, 219, 184, 22, 51, 49, 175, 27, 156,
+			129, 186, 26, 53, 125, 30, 203, 157, 50, 120, 73, 186, 139, 5,
+			194, 190, 35, 183, 40, 234, 210, 182, 136, 23, 212, 98, 184, 197,
+			253, 16, 24, 56, 212, 220, 157, 132, 132, 197, 222, 54, 74, 148,
+			144, 133, 126, 83, 53, 175, 33, 221, 0, 56, 200, 186, 53, 136,
+			28, 35, 251, 81, 113, 234, 229, 54, 16, 156, 114, 206, 196, 76,
+			104, 239, 46, 144, 162, 20, 239, 5, 201, 14, 79, 188, 70, 73,
+			60, 47, 203, 144, 163, 125, 237, 243, 146, 152, 197, 161, 143, 129,
+			88, 56, 115, 102, 185, 219, 216, 81, 77, 210, 93, 20, 47, 109,
+			243, 36, 198, 55, 224, 67, 250, 19, 226, 11, 115, 21, 182, 174,
+			74, 100, 163, 98, 198, 159, 242, 226, 36, 221, 89, 83, 59, 21,
+			232, 246, 17, 77, 106, 138, 141, 51, 117, 236, 12, 229, 219, 226,
+			149, 218, 65, 200, 180, 126, 17, 53, 121, 4, 150, 69, 43, 129,
+			181, 193, 247, 89, 41, 226, 174, 47, 123, 138, 17, 11, 89, 45,
+			64, 152, 51, 229, 125, 163, 166, 156, 60, 13, 48, 134, 196, 135,
+			99, 222, 118, 3, 232, 145, 136, 201, 43, 195, 64, 193, 24, 4,
+			97, 176, 16, 241, 14, 71, 155, 173, 23, 47, 115, 253, 93, 119,
+			79, 142, 145, 30, 53, 109, 188, 193, 140, 66, 251, 140, 128, 152,
+			195, 156, 57, 66, 232, 233, 79, 53, 121, 226, 122, 62, 112, 218,
+			238, 14, 215, 33, 90, 40, 25, 118, 163, 48, 225, 25, 126, 134,
+			149, 36, 8, 19, 220, 72, 241, 98, 21, 55, 209, 141, 121, 171,
+			235, 35, 115, 68, 97, 55, 104, 46, 36, 145, 135, 251, 249, 153,
+			253, 119, 177, 1, 131, 100, 105, 132, 65, 236, 197, 24, 36, 205,
+			118, 57, 65, 57, 188, 175, 79, 253, 131, 203, 92, 63, 14, 203,
+			140, 95, 231, 48, 148, 97, 119, 123, 71, 106, 67, 48, 118, 17,
+			127, 93, 215, 139, 56, 216, 149, 225, 62, 58, 108, 200, 233, 201,
+			49, 220, 203, 245, 253, 61, 185, 239, 234, 6, 137, 142, 90, 72,
+			82, 103, 92, 195, 13, 102, 96, 78, 114, 223, 103, 94, 75, 251,
+			161, 188, 236, 222, 77, 24, 49, 55, 64, 205, 174, 204, 226, 16,
+			90, 130, 172, 33, 71, 66, 141, 39, 233, 239, 4, 48, 6, 154,
+			210, 231, 250, 248, 58, 86, 92, 8, 98, 88, 204, 17, 223, 221,
+			46, 103, 155, 183, 199, 92, 63, 226, 110, 115, 79, 15, 35, 73,
+			145, 160, 146, 248, 68, 239, 233, 240, 39, 180, 236, 36, 54, 74,
+			45, 13, 58, 0, 106, 205, 22, 76, 225, 255, 110, 20, 148, 196,
+			35, 22, 128, 165, 147, 164, 68, 64, 27, 203, 253, 169, 49, 240,
+			103, 134, 81, 156, 234, 113, 205, 169, 222, 12, 19, 203, 6, 61,
+			231, 79, 141, 252, 17, 20, 182, 14, 216, 48, 95, 82, 246, 130,
+			131, 54, 204, 151, 212, 167, 29, 180, 97, 190, 164, 196, 182, 131,
+			54, 204, 151, 148, 13, 227, 160, 13, 243, 37, 101, 195, 56, 96,
+			76, 60, 175, 196, 182, 131, 54, 204, 243, 41, 38, 35, 7, 160,
+			20, 219, 14, 218, 48, 207, 43, 177, 237, 160, 13, 243, 60, 136,
+			237, 25, 98, 218, 57, 154, 251, 75, 99, 224, 29, 166, 81, 60,
+			148, 237, 68, 144, 106, 230, 178, 39, 160, 65, 252, 165, 145, 23,
+			150, 79, 14, 122, 242, 101, 213, 147, 28, 246, 228, 203, 234, 251,
+			57, 236, 201, 151, 85, 79, 114, 216, 147, 47, 171, 158, 228, 176,
+			39, 95, 86, 61, 201, 65, 79, 190, 98, 152, 21, 249, 16, 122,
+			242, 149, 20, 19, 244, 228, 43, 198, 240, 9, 5, 98, 229, 147,
+			115, 10, 180, 0, 44, 47, 72, 76, 38, 181, 191, 106, 152, 170,
+			46, 152, 29, 95, 77, 49, 129, 78, 249, 85, 99, 88, 53, 2,
+			62, 251, 85, 227, 182, 163, 10, 180, 0, 60, 94, 34, 207, 129,
+			2, 154, 179, 6, 104, 238, 31, 153, 230, 155, 77, 171, 248, 126,
+			243, 128, 173, 17, 165, 144, 10, 47, 91, 102, 19, 67, 186, 221,
+			14, 218, 24, 225, 65, 18, 121, 125, 187, 32, 64, 228, 3, 183,
+			64, 250, 118, 64, 216, 42, 136, 26, 25, 174, 43, 102, 108, 211,
+			139, 19, 47, 104, 36, 66, 115, 120, 193, 92, 44, 226, 147, 110,
+			130, 203, 41, 188, 173, 54, 76, 88, 220, 109, 236, 168, 71, 40,
+			176, 220, 78, 39, 10, 59, 145, 231, 38, 98, 15, 92, 106, 211,
+			216, 98, 185, 11, 238, 5, 201, 93, 231, 8, 107, 134, 109, 215,
+			11, 228, 52, 202, 89, 48, 198, 255, 200, 36, 135, 201, 17, 146,
+			3, 16, 24, 228, 77, 230, 190, 253, 25, 176, 237, 115, 98, 203,
+			16, 30, 143, 164, 5, 14, 20, 140, 210, 180, 192, 128, 130, 201,
+			233, 180, 192, 130, 130, 195, 71, 244, 23, 12, 106, 127, 143, 137,
+			155, 52, 7, 125, 1, 24, 233, 123, 178, 95, 0, 195, 254, 123,
+			178, 95, 48, 16, 193, 228, 173, 105, 129, 5, 5, 135, 138, 98,
+			119, 44, 7, 60, 241, 86, 211, 60, 123, 240, 238, 216, 13, 89,
+			160, 247, 65, 63, 43, 16, 208, 233, 112, 110, 201, 231, 253, 236,
+			192, 64, 127, 118, 69, 244, 202, 13, 89, 131, 100, 120, 67, 48,
+			48, 24, 232, 111, 53, 53, 183, 131, 129, 254, 86, 115, 248, 136,
+			2, 13, 0, 111, 63, 173, 64, 236, 89, 229, 12, 241, 177, 159,
+			54, 181, 223, 110, 154, 39, 139, 143, 167, 159, 75, 91, 120, 195,
+			29, 165, 136, 11, 141, 246, 192, 77, 35, 114, 208, 174, 145, 248,
+			184, 45, 62, 167, 65, 7, 64, 45, 43, 192, 168, 125, 187, 41,
+			13, 149, 28, 26, 181, 111, 55, 75, 39, 200, 157, 196, 180, 7,
+			105, 238, 7, 204, 129, 31, 50, 251, 227, 199, 68, 59, 213, 94,
+			136, 16, 93, 82, 106, 129, 209, 240, 3, 102, 94, 40, 187, 131,
+			192, 148, 239, 50, 165, 212, 26, 68, 38, 124, 151, 106, 201, 32,
+			178, 224, 187, 84, 75, 6, 145, 1, 223, 101, 74, 169, 53, 136,
+			236, 247, 46, 83, 74, 173, 65, 96, 190, 119, 155, 210, 135, 52,
+			136, 204, 246, 238, 20, 19, 176, 218, 187, 77, 105, 185, 12, 34,
+			163, 189, 219, 148, 62, 164, 65, 100, 179, 119, 155, 210, 135, 52,
+			8, 82, 235, 61, 166, 57, 43, 31, 194, 96, 188, 39, 197, 4,
+			82, 235, 61, 230, 176, 106, 49, 124, 246, 61, 230, 145, 19, 10,
+			180, 0, 188, 99, 134, 156, 36, 166, 157, 167, 185, 247, 153, 3,
+			255, 212, 52, 138, 183, 246, 80, 71, 30, 70, 151, 4, 1, 99,
+			224, 125, 102, 254, 40, 126, 60, 15, 4, 249, 97, 69, 144, 60,
+			18, 228, 135, 213, 199, 243, 72, 144, 31, 86, 4, 201, 35, 65,
+			126, 88, 17, 36, 143, 4, 249, 97, 69, 144, 60, 180, 236, 253,
+			166, 89, 150, 15, 129, 32, 239, 79, 49, 129, 24, 127, 191, 57,
+			204, 20, 136, 149, 143, 207, 40, 208, 2, 112, 254, 180, 196, 100,
+			82, 251, 3, 166, 121, 135, 124, 8, 4, 249, 64, 138, 9, 8,
+			242, 1, 115, 248, 144, 2, 13, 0, 139, 199, 21, 104, 1, 120,
+			242, 20, 57, 75, 76, 123, 136, 230, 126, 204, 28, 248, 168, 105,
+			20, 75, 125, 49, 38, 201, 78, 216, 20, 22, 109, 47, 113, 192,
+			190, 249, 49, 51, 127, 59, 54, 100, 8, 136, 243, 33, 69, 156,
+			33, 36, 206, 135, 84, 67, 134, 144, 56, 31, 82, 196, 25, 66,
+			226, 124, 72, 17, 103, 8, 137, 243, 33, 32, 206, 15, 26, 136,
+			202, 160, 246, 179, 166, 121, 188, 248, 63, 25, 172, 22, 96, 108,
+			102, 208, 84, 97, 154, 232, 119, 193, 89, 134, 6, 51, 143, 133,
+			19, 233, 64, 143, 203, 174, 187, 199, 220, 152, 176, 3, 211, 41,
+			105, 39, 76, 153, 109, 117, 19, 117, 18, 162, 5, 58, 107, 184,
+			63, 182, 70, 52, 20, 6, 234, 217, 180, 87, 192, 185, 207, 166,
+			189, 50, 176, 221, 244, 136, 2, 45, 0, 143, 49, 73, 31, 147,
+			218, 31, 54, 205, 146, 124, 8, 3, 245, 225, 20, 147, 233, 0,
+			168, 49, 1, 5, 62, 108, 210, 219, 21, 104, 1, 200, 142, 75,
+			76, 22, 181, 63, 98, 154, 167, 228, 67, 144, 101, 31, 73, 49,
+			129, 44, 251, 136, 57, 60, 173, 64, 3, 192, 67, 76, 129, 248,
+			238, 137, 147, 164, 138, 152, 108, 106, 63, 103, 154, 247, 21, 239,
+			99, 53, 117, 60, 47, 6, 77, 85, 88, 111, 76, 228, 2, 1,
+			99, 69, 100, 215, 80, 229, 42, 126, 90, 125, 211, 22, 120, 52,
+			232, 0, 40, 181, 194, 33, 20, 82, 207, 153, 5, 213, 4, 16,
+			82, 207, 65, 19, 36, 152, 7, 240, 212, 189, 10, 28, 4, 240,
+			236, 61, 178, 129, 14, 181, 127, 252, 160, 6, 138, 108, 36, 251,
+			27, 40, 203, 251, 27, 232, 216, 136, 71, 131, 136, 86, 55, 16,
+			212, 206, 31, 79, 27, 232, 88, 0, 234, 6, 58, 121, 0, 117,
+			3, 157, 65, 0, 207, 222, 67, 158, 27, 35, 166, 77, 104, 238,
+			63, 155, 3, 255, 209, 50, 206, 173, 178, 7, 190, 249, 127, 132,
+			201, 51, 169, 228, 220, 127, 28, 101, 85, 48, 94, 117, 100, 92,
+			26, 49, 41, 78, 10, 193, 226, 177, 227, 94, 215, 54, 85, 92,
+			98, 110, 34, 206, 200, 101, 103, 6, 97, 79, 162, 61, 161, 79,
+			177, 197, 153, 197, 73, 152, 4, 120, 28, 41, 9, 97, 17, 82,
+			166, 107, 147, 197, 190, 183, 189, 147, 248, 123, 172, 233, 181, 90,
+			60, 226, 65, 2, 235, 20, 152, 162, 238, 158, 242, 102, 177, 29,
+			15, 108, 252, 22, 90, 98, 77, 169, 114, 181, 221, 192, 235, 116,
+			125, 52, 14, 181, 187, 72, 13, 8, 104, 103, 42, 66, 0, 16,
+			29, 28, 33, 224, 198, 251, 34, 4, 56, 155, 151, 164, 201, 224,
+			146, 189, 204, 212, 85, 43, 238, 30, 199, 85, 87, 122, 17, 68,
+			160, 54, 90, 145, 30, 204, 247, 56, 236, 179, 210, 80, 73, 192,
+			208, 137, 212, 3, 170, 148, 141, 118, 133, 177, 90, 16, 39, 220,
+			109, 10, 3, 24, 3, 44, 224, 1, 186, 48, 149, 77, 23, 244,
+			52, 50, 245, 112, 55, 92, 223, 231, 77, 118, 208, 65, 225, 74,
+			214, 225, 135, 242, 7, 199, 83, 139, 47, 185, 47, 209, 136, 194,
+			56, 70, 215, 192, 126, 18, 176, 71, 185, 240, 154, 11, 211, 78,
+			99, 75, 66, 214, 9, 197, 40, 8, 239, 92, 134, 70, 187, 232,
+			101, 231, 108, 171, 235, 249, 77, 230, 102, 92, 25, 101, 32, 149,
+			224, 142, 78, 232, 5, 9, 126, 20, 199, 48, 22, 77, 219, 226,
+			60, 32, 130, 110, 98, 191, 55, 14, 177, 78, 6, 59, 8, 99,
+			36, 59, 12, 181, 62, 215, 170, 183, 229, 145, 87, 122, 71, 91,
+			30, 197, 106, 236, 132, 49, 71, 151, 143, 56, 72, 22, 159, 39,
+			108, 30, 173, 124, 85, 81, 180, 12, 61, 132, 42, 212, 6, 237,
+			5, 29, 215, 1, 67, 236, 115, 80, 203, 125, 121, 152, 143, 133,
+			17, 97, 140, 133, 209, 182, 27, 120, 79, 203, 243, 125, 97, 36,
+			182, 237, 158, 234, 240, 200, 195, 45, 104, 95, 125, 163, 140, 132,
+			148, 193, 65, 178, 201, 247, 156, 61, 123, 246, 44, 96, 73, 118,
+			34, 244, 22, 220, 15, 255, 148, 179, 95, 238, 76, 236, 133, 93,
+			113, 142, 44, 238, 70, 153, 204, 195, 77, 29, 74, 45, 134, 129,
+			201, 145, 69, 212, 216, 10, 45, 186, 100, 11, 42, 47, 208, 235,
+			45, 25, 119, 30, 239, 72, 242, 99, 247, 69, 40, 58, 198, 195,
+			106, 108, 240, 37, 47, 80, 225, 223, 9, 90, 35, 137, 7, 154,
+			41, 95, 104, 187, 158, 28, 213, 173, 110, 107, 97, 219, 15, 183,
+			92, 127, 65, 143, 224, 66, 196, 183, 189, 56, 137, 246, 50, 199,
+			115, 176, 243, 161, 210, 80, 51, 161, 106, 58, 224, 109, 221, 107,
+			119, 252, 61, 117, 128, 79, 100, 77, 238, 68, 33, 166, 13, 70,
+			85, 23, 15, 177, 0, 158, 53, 204, 37, 236, 93, 231, 11, 75,
+			172, 227, 119, 183, 189, 96, 14, 187, 210, 243, 202, 46, 223, 138,
+			189, 132, 179, 89, 175, 197, 220, 235, 174, 231, 187, 91, 62, 159,
+			147, 33, 193, 17, 159, 137, 89, 16, 2, 50, 220, 225, 3, 178,
+			63, 213, 241, 81, 30, 133, 187, 72, 118, 152, 107, 65, 83, 37,
+			103, 22, 147, 248, 106, 220, 69, 143, 13, 60, 71, 246, 193, 151,
+			195, 0, 105, 213, 223, 165, 10, 6, 56, 139, 24, 38, 48, 48,
+			248, 190, 113, 146, 135, 72, 1, 81, 24, 100, 104, 130, 205, 18,
+			195, 187, 181, 199, 58, 221, 36, 145, 222, 43, 41, 46, 226, 238,
+			214, 66, 79, 172, 19, 238, 114, 136, 25, 161, 166, 119, 44, 78,
+			189, 176, 176, 37, 216, 14, 143, 79, 198, 146, 105, 49, 245, 52,
+			76, 14, 246, 245, 30, 150, 5, 36, 162, 244, 68, 168, 196, 148,
+			220, 41, 244, 98, 38, 210, 71, 99, 216, 55, 46, 5, 66, 126,
+			128, 76, 128, 225, 208, 57, 169, 21, 35, 198, 60, 97, 221, 142,
+			228, 12, 183, 155, 132, 109, 55, 241, 26, 72, 97, 55, 198, 131,
+			89, 210, 235, 175, 120, 68, 40, 141, 196, 160, 246, 127, 54, 243,
+			147, 34, 54, 157, 128, 214, 248, 41, 211, 60, 81, 252, 140, 193,
+			214, 121, 34, 220, 137, 143, 184, 215, 93, 38, 179, 17, 129, 180,
+			138, 64, 241, 112, 227, 152, 199, 153, 245, 73, 110, 1, 120, 177,
+			242, 10, 202, 134, 17, 214, 241, 221, 6, 174, 129, 23, 247, 212,
+			62, 84, 57, 19, 190, 173, 16, 195, 212, 141, 121, 83, 104, 127,
+			202, 79, 16, 182, 18, 144, 112, 94, 144, 49, 237, 181, 235, 172,
+			231, 125, 237, 103, 11, 194, 168, 141, 221, 198, 93, 86, 193, 22,
+			91, 110, 227, 218, 174, 27, 53, 99, 101, 249, 75, 141, 85, 40,
+			32, 4, 181, 227, 79, 41, 125, 132, 160, 118, 252, 41, 165, 253,
+			17, 212, 142, 63, 101, 210, 163, 10, 180, 0, 60, 94, 34, 127,
+			106, 34, 201, 12, 106, 255, 129, 105, 158, 46, 126, 218, 100, 75,
+			97, 144, 68, 161, 191, 127, 119, 114, 55, 114, 59, 29, 30, 9,
+			82, 34, 241, 178, 164, 147, 199, 99, 43, 125, 241, 236, 110, 34,
+			171, 34, 41, 149, 7, 82, 46, 245, 125, 47, 204, 0, 194, 36,
+			213, 169, 103, 231, 148, 165, 0, 10, 247, 46, 7, 4, 49, 250,
+			161, 211, 83, 31, 153, 85, 66, 172, 251, 122, 217, 236, 109, 73,
+			173, 197, 14, 200, 250, 38, 221, 56, 32, 9, 212, 86, 49, 44,
+			61, 184, 171, 137, 174, 102, 197, 35, 106, 115, 136, 244, 28, 204,
+			83, 124, 43, 247, 164, 189, 32, 246, 154, 114, 173, 21, 171, 134,
+			34, 25, 102, 167, 19, 200, 244, 120, 129, 222, 255, 7, 233, 120,
+			129, 222, 255, 7, 233, 120, 25, 56, 34, 244, 14, 5, 90, 0,
+			206, 205, 147, 127, 102, 225, 120, 153, 212, 254, 188, 105, 94, 40,
+			254, 160, 5, 61, 19, 105, 201, 84, 15, 52, 187, 163, 254, 36,
+			135, 8, 79, 166, 251, 126, 102, 31, 129, 197, 188, 227, 226, 207,
+			10, 80, 70, 156, 64, 19, 242, 0, 116, 196, 148, 196, 82, 178,
+			148, 165, 67, 25, 151, 104, 57, 113, 15, 36, 57, 145, 206, 116,
+			182, 177, 211, 197, 205, 110, 46, 51, 213, 75, 38, 152, 15, 194,
+			100, 254, 96, 178, 41, 122, 33, 165, 196, 134, 60, 198, 204, 30,
+			148, 226, 175, 194, 210, 0, 176, 125, 239, 138, 47, 169, 192, 47,
+			146, 97, 212, 36, 236, 97, 191, 23, 227, 187, 44, 219, 145, 155,
+			226, 59, 121, 230, 72, 140, 27, 88, 67, 159, 79, 7, 25, 108,
+			143, 207, 43, 35, 129, 160, 73, 246, 121, 179, 112, 66, 129, 22,
+			128, 119, 204, 42, 48, 15, 224, 220, 121, 5, 14, 2, 120, 247,
+			253, 100, 14, 57, 192, 162, 246, 23, 76, 179, 90, 60, 44, 20,
+			62, 41, 24, 155, 33, 199, 61, 135, 29, 17, 170, 36, 222, 4,
+			107, 238, 11, 105, 35, 44, 7, 64, 221, 8, 176, 230, 190, 96,
+			22, 22, 20, 136, 136, 207, 158, 83, 96, 30, 192, 187, 150, 49,
+			104, 8, 64, 124, 124, 247, 18, 249, 172, 16, 28, 54, 181, 255,
+			194, 52, 95, 81, 252, 191, 76, 185, 113, 173, 119, 250, 50, 156,
+			120, 238, 133, 89, 81, 235, 249, 4, 213, 161, 93, 225, 253, 126,
+			170, 193, 69, 159, 64, 183, 196, 173, 34, 23, 212, 238, 132, 183,
+			59, 168, 35, 181, 93, 97, 97, 200, 149, 193, 197, 77, 168, 171,
+			27, 15, 45, 188, 140, 224, 94, 62, 139, 249, 235, 186, 184, 67,
+			136, 182, 183, 136, 41, 144, 59, 131, 132, 201, 148, 110, 153, 243,
+			160, 162, 81, 205, 80, 235, 202, 21, 146, 50, 152, 155, 137, 47,
+			150, 170, 156, 96, 46, 183, 1, 205, 140, 211, 143, 247, 126, 59,
+			86, 123, 81, 114, 124, 118, 92, 168, 202, 120, 171, 5, 42, 137,
+			236, 92, 170, 113, 226, 183, 125, 208, 82, 228, 153, 38, 61, 134,
+			182, 32, 180, 6, 29, 0, 245, 24, 130, 57, 252, 23, 102, 65,
+			73, 11, 48, 135, 255, 194, 156, 59, 173, 192, 60, 128, 229, 7,
+			20, 56, 8, 224, 125, 47, 39, 43, 48, 130, 246, 0, 205, 125,
+			197, 52, 255, 214, 180, 138, 47, 103, 58, 135, 137, 22, 124, 114,
+			15, 237, 160, 35, 181, 202, 66, 139, 189, 167, 117, 59, 49, 178,
+			249, 43, 230, 224, 36, 89, 6, 126, 17, 145, 205, 127, 109, 218,
+			99, 165, 187, 53, 242, 244, 180, 41, 190, 14, 8, 165, 41, 85,
+			6, 193, 226, 185, 190, 210, 170, 133, 47, 153, 200, 80, 230, 191,
+			54, 237, 161, 180, 192, 132, 130, 145, 81, 114, 73, 126, 199, 160,
+			246, 215, 76, 155, 22, 115, 226, 64, 104, 233, 12, 70, 231, 166,
+			121, 72, 214, 58, 120, 177, 134, 14, 17, 149, 114, 73, 76, 116,
+			21, 204, 73, 100, 12, 244, 215, 76, 25, 208, 72, 100, 12, 244,
+			215, 204, 194, 132, 254, 148, 73, 237, 191, 49, 237, 91, 74, 247,
+			101, 187, 212, 4, 195, 1, 56, 76, 114, 214, 138, 151, 112, 157,
+			198, 162, 111, 72, 21, 102, 104, 244, 223, 152, 50, 204, 147, 200,
+			152, 230, 191, 49, 39, 167, 208, 41, 67, 64, 90, 60, 99, 153,
+			23, 228, 184, 57, 54, 130, 138, 5, 156, 28, 128, 114, 139, 137,
+			160, 195, 225, 25, 107, 250, 164, 2, 45, 0, 103, 148, 44, 113,
+			242, 0, 106, 89, 226, 12, 2, 120, 247, 253, 228, 211, 98, 22,
+			231, 168, 253, 102, 203, 44, 21, 255, 79, 51, 213, 152, 46, 133,
+			125, 250, 82, 156, 68, 152, 187, 226, 235, 209, 151, 106, 45, 22,
+			138, 156, 39, 229, 125, 72, 229, 226, 153, 77, 133, 209, 155, 243,
+			3, 245, 209, 5, 140, 25, 217, 114, 99, 158, 213, 69, 180, 194,
+			133, 91, 165, 172, 227, 38, 59, 101, 230, 181, 116, 182, 143, 138,
+			120, 53, 19, 235, 145, 125, 43, 78, 220, 68, 48, 193, 126, 93,
+			65, 162, 193, 77, 151, 131, 177, 244, 183, 165, 231, 101, 117, 54,
+			81, 75, 11, 61, 131, 115, 54, 18, 89, 131, 14, 128, 122, 189,
+			207, 25, 0, 74, 63, 31, 193, 240, 160, 55, 91, 199, 142, 147,
+			223, 180, 113, 128, 6, 169, 253, 110, 203, 188, 80, 252, 101, 155,
+			173, 139, 160, 102, 117, 61, 141, 202, 116, 218, 235, 105, 241, 2,
+			177, 138, 251, 110, 176, 221, 117, 183, 249, 131, 140, 149, 100, 230,
+			211, 146, 126, 69, 196, 39, 226, 190, 174, 74, 53, 2, 162, 50,
+			216, 131, 249, 152, 120, 13, 80, 213, 89, 253, 202, 18, 139, 247,
+			226, 4, 125, 22, 27, 184, 209, 27, 101, 191, 132, 39, 163, 57,
+			97, 168, 145, 246, 138, 249, 120, 95, 51, 216, 172, 78, 75, 208,
+			20, 169, 94, 92, 95, 90, 108, 241, 92, 133, 176, 75, 253, 157,
+			218, 229, 210, 125, 130, 70, 209, 53, 47, 64, 175, 177, 82, 63,
+			228, 151, 64, 176, 166, 1, 226, 184, 211, 231, 70, 254, 158, 10,
+			137, 70, 111, 79, 127, 182, 38, 114, 192, 199, 4, 53, 118, 69,
+			4, 128, 8, 161, 200, 132, 162, 123, 1, 107, 185, 215, 67, 12,
+			157, 18, 51, 93, 54, 156, 8, 211, 188, 119, 57, 203, 82, 84,
+			152, 161, 55, 38, 105, 196, 91, 97, 196, 203, 68, 138, 36, 29,
+			231, 22, 50, 140, 138, 171, 48, 182, 134, 135, 107, 155, 92, 26,
+			238, 194, 6, 143, 97, 253, 216, 199, 6, 34, 230, 157, 160, 249,
+			234, 53, 188, 4, 140, 8, 220, 181, 231, 109, 17, 126, 208, 77,
+			23, 149, 65, 27, 217, 74, 131, 14, 128, 122, 81, 25, 52, 0,
+			212, 218, 201, 160, 5, 160, 214, 78, 6, 243, 0, 106, 137, 50,
+			136, 28, 122, 247, 253, 82, 114, 229, 169, 253, 131, 150, 169, 86,
+			156, 188, 141, 160, 250, 78, 222, 1, 80, 127, 39, 111, 0, 88,
+			56, 165, 64, 11, 192, 217, 121, 5, 34, 170, 211, 47, 87, 224,
+			32, 128, 247, 94, 144, 223, 25, 162, 246, 123, 82, 9, 57, 100,
+			35, 168, 190, 51, 228, 0, 168, 191, 51, 100, 0, 168, 251, 51,
+			100, 1, 168, 251, 51, 148, 7, 80, 247, 103, 104, 16, 64, 221,
+			31, 66, 237, 31, 178, 76, 213, 8, 98, 35, 168, 190, 67, 28,
+			0, 245, 119, 192, 28, 253, 33, 171, 160, 36, 49, 177, 0, 156,
+			153, 83, 96, 30, 192, 121, 213, 100, 50, 8, 224, 61, 231, 201,
+			243, 194, 118, 29, 166, 246, 143, 88, 230, 185, 226, 31, 26, 172,
+			22, 247, 36, 18, 81, 140, 248, 32, 97, 226, 138, 31, 96, 193,
+			80, 200, 175, 196, 141, 182, 121, 2, 82, 55, 105, 133, 81, 91,
+			6, 90, 193, 218, 205, 219, 94, 2, 245, 211, 3, 21, 218, 103,
+			75, 132, 178, 127, 157, 71, 123, 168, 47, 102, 21, 88, 116, 104,
+			121, 137, 22, 210, 106, 201, 246, 247, 152, 183, 29, 132, 17, 111,
+			94, 80, 213, 225, 125, 194, 124, 238, 198, 73, 54, 216, 12, 143,
+			83, 168, 149, 28, 191, 164, 186, 32, 212, 48, 63, 99, 186, 14,
+			219, 216, 107, 13, 58, 0, 106, 122, 14, 27, 0, 22, 138, 10,
+			180, 0, 188, 253, 152, 2, 243, 0, 178, 59, 21, 56, 8, 224,
+			233, 179, 228, 187, 144, 156, 35, 212, 254, 81, 203, 188, 175, 248,
+			58, 38, 178, 51, 199, 42, 120, 8, 55, 159, 48, 85, 179, 54,
+			93, 101, 18, 145, 131, 242, 184, 72, 175, 42, 58, 1, 57, 134,
+			148, 137, 220, 43, 219, 251, 244, 37, 64, 182, 116, 250, 180, 238,
+			218, 136, 141, 45, 208, 160, 3, 160, 238, 26, 30, 183, 176, 228,
+			46, 1, 49, 71, 44, 0, 79, 168, 153, 48, 146, 7, 240, 142,
+			123, 21, 56, 8, 224, 157, 247, 144, 239, 23, 172, 50, 74, 237,
+			127, 97, 153, 115, 197, 55, 102, 220, 28, 161, 242, 132, 177, 134,
+			52, 132, 68, 6, 104, 41, 56, 60, 132, 209, 129, 135, 150, 16,
+			24, 187, 153, 87, 200, 65, 29, 234, 91, 227, 43, 66, 98, 137,
+			216, 38, 37, 171, 116, 119, 71, 109, 108, 148, 6, 29, 0, 245,
+			34, 55, 106, 0, 72, 213, 12, 28, 181, 0, 188, 99, 150, 212,
+			176, 63, 99, 212, 254, 151, 150, 57, 91, 188, 192, 116, 30, 105,
+			36, 231, 190, 54, 93, 80, 159, 141, 85, 252, 182, 92, 216, 117,
+			51, 198, 108, 196, 165, 65, 7, 64, 221, 140, 49, 3, 64, 90,
+			82, 160, 5, 224, 169, 25, 242, 14, 161, 12, 141, 83, 251, 199,
+			45, 243, 100, 241, 187, 205, 140, 183, 135, 173, 239, 122, 173, 36,
+			187, 182, 225, 212, 192, 19, 97, 251, 253, 64, 160, 247, 45, 169,
+			32, 81, 12, 250, 136, 56, 168, 68, 192, 250, 51, 149, 25, 161,
+			235, 119, 131, 38, 143, 226, 70, 24, 113, 157, 96, 78, 196, 151,
+			132, 106, 208, 84, 76, 120, 124, 38, 222, 107, 111, 133, 126, 76,
+			148, 177, 41, 35, 55, 147, 212, 180, 136, 197, 224, 10, 245, 167,
+			44, 66, 178, 176, 137, 218, 139, 47, 227, 110, 197, 182, 3, 121,
+			161, 207, 232, 175, 40, 2, 142, 219, 72, 19, 13, 58, 0, 106,
+			122, 142, 27, 0, 106, 223, 210, 184, 5, 224, 241, 19, 24, 6,
+			79, 204, 2, 181, 255, 87, 24, 214, 78, 202, 165, 157, 157, 206,
+			205, 114, 39, 84, 221, 199, 0, 228, 0, 174, 92, 150, 227, 164,
+			110, 153, 211, 109, 47, 216, 216, 0, 13, 58, 0, 234, 182, 23,
+			12, 0, 53, 47, 20, 44, 0, 79, 205, 144, 255, 93, 204, 177,
+			9, 106, 255, 27, 203, 60, 85, 124, 206, 64, 115, 34, 67, 111,
+			116, 36, 136, 204, 132, 58, 45, 28, 178, 108, 216, 58, 184, 209,
+			186, 137, 36, 109, 227, 190, 81, 212, 143, 122, 21, 86, 84, 55,
+			179, 97, 204, 48, 49, 128, 25, 18, 30, 181, 211, 92, 66, 186,
+			17, 186, 243, 19, 54, 118, 64, 131, 14, 128, 186, 243, 19, 6,
+			128, 84, 137, 210, 9, 11, 192, 210, 73, 242, 107, 162, 243, 148,
+			218, 31, 183, 204, 74, 241, 127, 251, 38, 58, 175, 242, 198, 107,
+			42, 144, 253, 35, 245, 162, 84, 72, 61, 110, 89, 66, 16, 77,
+			137, 155, 32, 4, 181, 177, 51, 26, 116, 0, 212, 132, 160, 6,
+			128, 84, 233, 2, 212, 2, 240, 244, 2, 249, 69, 65, 136, 73,
+			106, 255, 91, 144, 8, 63, 241, 98, 132, 80, 227, 21, 182, 88,
+			212, 221, 218, 251, 38, 152, 64, 70, 91, 126, 67, 108, 128, 159,
+			238, 23, 137, 147, 54, 118, 66, 131, 14, 128, 154, 0, 147, 6,
+			128, 122, 10, 79, 90, 0, 30, 63, 65, 94, 143, 253, 159, 162,
+			246, 175, 90, 230, 249, 98, 240, 13, 157, 10, 39, 122, 75, 162,
+			55, 97, 163, 90, 119, 75, 107, 106, 175, 89, 237, 81, 100, 143,
+			144, 19, 115, 202, 198, 207, 107, 48, 7, 224, 176, 106, 234, 148,
+			1, 160, 60, 66, 78, 204, 41, 11, 192, 123, 239, 39, 111, 54,
+			136, 101, 131, 102, 246, 235, 150, 121, 168, 248, 244, 55, 125, 134,
+			252, 27, 239, 5, 106, 144, 206, 0, 180, 68, 30, 56, 39, 120,
+			224, 252, 215, 45, 121, 224, 156, 224, 129, 243, 95, 183, 110, 153,
+			22, 219, 25, 67, 212, 254, 15, 150, 57, 38, 94, 28, 26, 0,
+			104, 120, 84, 212, 28, 130, 23, 179, 160, 41, 193, 17, 98, 218,
+			195, 52, 247, 27, 214, 192, 15, 216, 6, 162, 1, 181, 233, 55,
+			172, 252, 173, 228, 183, 29, 98, 219, 195, 230, 0, 181, 63, 99,
+			153, 175, 40, 254, 170, 3, 130, 24, 13, 129, 204, 102, 86, 26,
+			103, 127, 167, 242, 96, 64, 173, 236, 201, 216, 86, 207, 41, 38,
+			229, 216, 202, 172, 58, 88, 67, 111, 83, 44, 128, 206, 232, 38,
+			222, 150, 135, 217, 120, 180, 123, 171, 15, 59, 145, 232, 43, 12,
+			183, 199, 228, 145, 221, 116, 131, 211, 19, 58, 85, 154, 198, 78,
+			156, 115, 63, 207, 88, 45, 153, 137, 153, 207, 227, 152, 48, 222,
+			106, 121, 13, 15, 207, 117, 237, 128, 70, 199, 119, 121, 196, 90,
+			220, 77, 186, 17, 143, 133, 203, 26, 134, 18, 150, 90, 212, 100,
+			197, 221, 172, 125, 169, 251, 116, 252, 188, 114, 232, 242, 167, 92,
+			76, 178, 215, 179, 125, 204, 116, 245, 204, 77, 170, 114, 222, 222,
+			224, 86, 8, 246, 0, 82, 91, 94, 151, 154, 225, 177, 187, 97,
+			0, 218, 238, 83, 248, 228, 13, 189, 49, 166, 60, 179, 219, 15,
+			58, 187, 216, 44, 7, 50, 168, 230, 137, 208, 236, 11, 25, 130,
+			198, 50, 242, 15, 171, 102, 135, 138, 224, 9, 167, 94, 238, 70,
+			171, 20, 35, 159, 84, 191, 193, 168, 69, 3, 85, 233, 192, 23,
+			68, 206, 53, 212, 37, 84, 68, 195, 150, 140, 63, 141, 197, 126,
+			64, 140, 150, 66, 127, 236, 63, 126, 242, 162, 142, 14, 151, 54,
+			129, 218, 190, 19, 66, 77, 68, 156, 36, 187, 184, 7, 156, 68,
+			94, 67, 231, 188, 197, 209, 231, 65, 43, 140, 26, 210, 158, 79,
+			14, 74, 180, 39, 5, 196, 48, 238, 124, 125, 70, 9, 136, 97,
+			220, 249, 250, 140, 210, 177, 135, 209, 117, 248, 25, 171, 48, 163,
+			64, 11, 192, 249, 211, 10, 204, 3, 40, 125, 163, 195, 230, 192,
+			32, 128, 247, 189, 156, 124, 222, 192, 73, 99, 80, 251, 143, 45,
+			243, 161, 226, 127, 53, 216, 178, 216, 39, 18, 26, 76, 198, 243,
+			32, 189, 64, 234, 66, 15, 86, 106, 102, 54, 18, 74, 76, 93,
+			234, 161, 226, 55, 27, 110, 128, 105, 99, 91, 190, 215, 72, 212,
+			241, 73, 225, 81, 86, 152, 84, 156, 134, 58, 189, 133, 82, 200,
+			197, 100, 84, 172, 13, 10, 167, 78, 174, 38, 21, 33, 57, 111,
+			185, 27, 123, 60, 186, 192, 2, 190, 43, 157, 16, 98, 50, 137,
+			203, 137, 101, 36, 191, 216, 83, 201, 52, 178, 164, 233, 104, 216,
+			216, 91, 13, 58, 0, 106, 58, 26, 72, 139, 194, 25, 5, 90,
+			0, 158, 187, 75, 129, 121, 0, 239, 174, 42, 112, 16, 192, 7,
+			151, 201, 23, 4, 29, 77, 106, 127, 209, 50, 239, 44, 254, 94,
+			106, 214, 42, 246, 126, 201, 44, 219, 204, 28, 250, 58, 205, 89,
+			105, 205, 146, 155, 54, 103, 51, 108, 47, 250, 111, 218, 216, 97,
+			13, 58, 0, 106, 82, 2, 91, 125, 81, 89, 180, 195, 184, 239,
+			243, 69, 235, 246, 163, 10, 204, 3, 120, 236, 172, 2, 7, 1,
+			156, 63, 35, 132, 250, 16, 181, 191, 100, 153, 147, 184, 54, 12,
+			195, 218, 240, 37, 107, 120, 68, 212, 196, 181, 33, 11, 154, 18,
+			20, 117, 241, 225, 152, 108, 194, 144, 209, 7, 154, 18, 20, 117,
+			17, 154, 160, 242, 161, 105, 244, 130, 234, 233, 7, 114, 56, 182,
+			22, 181, 191, 215, 54, 143, 22, 223, 145, 3, 77, 70, 159, 208,
+			81, 227, 43, 102, 126, 239, 110, 126, 70, 63, 116, 59, 24, 151,
+			191, 39, 68, 145, 28, 55, 2, 229, 234, 24, 151, 58, 174, 164,
+			75, 132, 24, 102, 80, 242, 242, 87, 242, 189, 141, 189, 14, 47,
+			51, 76, 155, 15, 63, 95, 1, 229, 155, 98, 70, 61, 192, 238,
+			188, 64, 82, 165, 165, 153, 61, 98, 229, 135, 225, 181, 24, 83,
+			115, 40, 116, 178, 193, 151, 221, 14, 6, 119, 226, 229, 56, 233,
+			93, 217, 169, 148, 87, 23, 233, 244, 202, 245, 180, 134, 235, 51,
+			217, 44, 118, 141, 239, 201, 70, 236, 171, 162, 27, 44, 13, 179,
+			7, 216, 57, 125, 161, 182, 184, 156, 91, 9, 213, 222, 6, 245,
+			245, 142, 176, 90, 95, 214, 11, 12, 166, 219, 9, 195, 88, 8,
+			210, 140, 155, 66, 140, 139, 106, 254, 3, 168, 2, 232, 25, 178,
+			213, 77, 80, 163, 102, 46, 11, 68, 6, 19, 24, 27, 175, 103,
+			26, 106, 63, 110, 18, 178, 29, 208, 24, 224, 217, 53, 190, 39,
+			14, 190, 203, 64, 124, 65, 240, 204, 198, 218, 226, 149, 26, 106,
+			87, 120, 214, 97, 95, 142, 14, 220, 70, 83, 65, 55, 152, 123,
+			213, 141, 9, 243, 90, 233, 89, 76, 49, 3, 15, 62, 95, 134,
+			71, 51, 214, 54, 170, 231, 85, 134, 73, 233, 236, 212, 170, 116,
+			95, 134, 93, 182, 40, 66, 16, 148, 218, 131, 92, 37, 50, 242,
+			17, 101, 27, 139, 99, 170, 18, 129, 148, 161, 42, 56, 204, 107,
+			247, 184, 85, 133, 247, 91, 90, 40, 106, 97, 146, 209, 108, 233,
+			2, 101, 217, 56, 69, 52, 232, 0, 168, 165, 129, 101, 0, 88,
+			56, 164, 64, 156, 79, 71, 110, 39, 39, 229, 124, 255, 199, 182,
+			57, 90, 186, 13, 247, 192, 125, 47, 1, 237, 66, 238, 145, 109,
+			249, 156, 200, 9, 107, 65, 53, 61, 243, 1, 99, 22, 52, 37,
+			120, 76, 98, 124, 43, 96, 164, 136, 49, 112, 131, 112, 211, 141,
+			55, 1, 179, 66, 102, 67, 13, 253, 182, 109, 244, 130, 166, 4,
+			215, 112, 242, 219, 212, 126, 187, 253, 173, 75, 24, 53, 44, 14,
+			39, 164, 180, 178, 115, 0, 14, 43, 217, 136, 135, 19, 236, 99,
+			106, 213, 193, 195, 9, 182, 76, 24, 53, 236, 80, 251, 157, 246,
+			75, 147, 48, 106, 24, 244, 247, 119, 218, 82, 127, 31, 70, 253,
+			253, 157, 182, 212, 223, 135, 81, 127, 127, 167, 125, 203, 52, 170,
+			225, 35, 52, 247, 110, 123, 224, 79, 165, 26, 62, 98, 80, 251,
+			221, 118, 126, 138, 252, 168, 73, 108, 123, 4, 212, 240, 247, 218,
+			102, 165, 248, 3, 38, 82, 172, 33, 174, 240, 87, 204, 170, 246,
+			216, 48, 128, 235, 244, 233, 254, 125, 115, 169, 175, 187, 105, 0,
+			45, 185, 193, 129, 123, 25, 178, 185, 227, 6, 64, 126, 29, 79,
+			180, 11, 204, 92, 97, 218, 150, 81, 219, 20, 68, 19, 102, 139,
+			251, 225, 174, 210, 60, 122, 237, 209, 61, 158, 164, 211, 55, 13,
+			120, 8, 59, 92, 221, 139, 0, 173, 129, 181, 147, 179, 133, 5,
+			22, 135, 81, 180, 87, 102, 187, 124, 198, 247, 25, 74, 248, 80,
+			28, 124, 106, 114, 60, 28, 137, 129, 174, 93, 80, 209, 213, 78,
+			205, 113, 49, 234, 35, 168, 209, 189, 87, 49, 193, 8, 102, 13,
+			123, 175, 45, 79, 115, 140, 160, 70, 247, 94, 91, 230, 241, 24,
+			65, 141, 238, 189, 246, 161, 162, 2, 243, 0, 30, 94, 80, 224,
+			32, 128, 119, 148, 49, 41, 203, 136, 61, 64, 115, 239, 179, 205,
+			31, 181, 69, 82, 150, 17, 220, 175, 126, 159, 61, 8, 115, 35,
+			7, 32, 140, 207, 251, 109, 123, 188, 56, 174, 253, 20, 109, 204,
+			78, 138, 123, 180, 35, 114, 43, 250, 253, 182, 157, 41, 48, 161,
+			96, 116, 12, 67, 36, 70, 196, 86, 244, 7, 108, 185, 129, 60,
+			34, 119, 148, 63, 96, 219, 249, 180, 192, 132, 130, 225, 17, 253,
+			134, 73, 237, 15, 218, 50, 13, 207, 136, 220, 24, 254, 160, 45,
+			55, 134, 71, 228, 198, 240, 7, 237, 201, 41, 242, 59, 130, 139,
+			12, 106, 63, 107, 155, 135, 139, 159, 48, 229, 188, 195, 243, 207,
+			114, 184, 228, 158, 189, 140, 10, 18, 7, 95, 149, 240, 236, 68,
+			94, 27, 175, 193, 81, 250, 32, 6, 152, 162, 40, 97, 174, 176,
+			144, 180, 49, 181, 143, 181, 196, 120, 131, 101, 83, 97, 117, 87,
+			174, 244, 110, 160, 177, 131, 237, 177, 27, 121, 42, 58, 17, 51,
+			21, 170, 140, 40, 105, 100, 17, 23, 12, 84, 206, 30, 105, 115,
+			163, 200, 221, 67, 151, 136, 200, 190, 133, 107, 128, 14, 190, 245,
+			251, 115, 38, 109, 249, 225, 86, 133, 213, 212, 73, 243, 178, 16,
+			207, 106, 203, 11, 36, 115, 34, 242, 153, 227, 97, 114, 220, 69,
+			147, 1, 104, 168, 10, 203, 237, 59, 65, 180, 76, 102, 31, 193,
+			49, 120, 32, 35, 101, 62, 60, 144, 161, 164, 245, 136, 56, 144,
+			97, 23, 20, 243, 225, 129, 12, 96, 190, 127, 226, 224, 192, 152,
+			212, 254, 41, 219, 188, 171, 248, 143, 29, 28, 24, 113, 83, 156,
+			142, 206, 145, 142, 26, 158, 198, 36, 174, 163, 58, 34, 40, 164,
+			221, 89, 50, 86, 60, 148, 89, 16, 228, 105, 246, 236, 170, 4,
+			86, 183, 190, 77, 4, 59, 15, 239, 221, 123, 55, 219, 194, 153,
+			149, 240, 237, 200, 245, 145, 246, 45, 239, 41, 149, 46, 133, 176,
+			89, 47, 72, 238, 189, 187, 204, 186, 242, 111, 44, 255, 98, 37,
+			44, 144, 191, 230, 42, 140, 45, 102, 82, 220, 169, 142, 232, 235,
+			222, 136, 200, 88, 36, 249, 3, 7, 44, 219, 31, 17, 109, 163,
+			108, 30, 164, 122, 204, 252, 80, 100, 12, 0, 189, 217, 195, 72,
+			26, 17, 244, 3, 252, 186, 227, 118, 64, 140, 236, 138, 124, 4,
+			62, 168, 27, 105, 162, 8, 153, 129, 65, 122, 133, 89, 203, 15,
+			133, 218, 45, 2, 202, 211, 207, 86, 8, 91, 71, 129, 182, 7,
+			79, 245, 93, 116, 218, 26, 144, 157, 64, 179, 180, 199, 142, 227,
+			205, 108, 227, 229, 217, 5, 162, 149, 132, 204, 179, 146, 8, 61,
+			45, 169, 156, 56, 24, 156, 192, 119, 220, 235, 94, 24, 101, 206,
+			85, 160, 224, 16, 99, 69, 152, 190, 50, 15, 143, 126, 246, 232,
+			63, 58, 155, 118, 34, 156, 17, 61, 50, 87, 159, 1, 14, 229,
+			96, 103, 119, 205, 69, 64, 157, 8, 171, 117, 155, 232, 243, 23,
+			225, 208, 219, 97, 184, 93, 105, 187, 201, 78, 165, 6, 124, 160,
+			213, 144, 17, 52, 74, 126, 42, 101, 108, 51, 7, 160, 116, 1,
+			142, 160, 204, 249, 41, 155, 78, 43, 208, 2, 240, 240, 17, 5,
+			230, 1, 188, 253, 156, 2, 7, 1, 156, 189, 83, 74, 85, 131,
+			230, 126, 218, 54, 127, 94, 75, 85, 152, 36, 63, 109, 15, 142,
+			98, 82, 173, 17, 145, 234, 234, 103, 109, 155, 22, 111, 147, 14,
+			212, 204, 46, 183, 56, 11, 37, 4, 157, 200, 115, 245, 179, 169,
+			240, 20, 121, 174, 126, 214, 46, 76, 144, 57, 137, 202, 160, 246,
+			207, 1, 170, 67, 136, 106, 31, 207, 197, 25, 100, 134, 168, 155,
+			34, 3, 193, 251, 115, 89, 100, 38, 181, 63, 126, 32, 178, 52,
+			194, 88, 189, 11, 31, 254, 120, 22, 153, 120, 185, 48, 65, 254,
+			100, 4, 167, 190, 69, 237, 223, 181, 205, 211, 197, 223, 31, 81,
+			49, 26, 153, 195, 19, 91, 218, 4, 241, 221, 167, 61, 127, 239,
+			65, 198, 86, 220, 167, 247, 212, 150, 162, 222, 81, 148, 26, 200,
+			2, 144, 69, 165, 114, 21, 199, 2, 218, 220, 13, 100, 238, 139,
+			93, 21, 92, 39, 226, 74, 51, 150, 22, 158, 231, 193, 149, 94,
+			124, 173, 44, 36, 136, 135, 89, 130, 100, 189, 153, 56, 205, 158,
+			131, 66, 81, 30, 39, 149, 237, 219, 234, 38, 74, 17, 22, 138,
+			156, 136, 38, 17, 114, 89, 120, 235, 36, 223, 247, 96, 149, 242,
+			181, 145, 136, 136, 248, 20, 31, 54, 86, 166, 134, 194, 44, 78,
+			194, 35, 162, 220, 111, 50, 70, 26, 186, 239, 226, 217, 31, 232,
+			109, 159, 141, 224, 70, 156, 181, 34, 206, 133, 183, 29, 45, 27,
+			157, 11, 2, 53, 34, 194, 184, 187, 205, 35, 176, 242, 125, 160,
+			170, 58, 154, 211, 155, 169, 68, 31, 195, 209, 250, 158, 58, 51,
+			163, 3, 253, 136, 94, 59, 122, 156, 252, 96, 40, 197, 221, 237,
+			109, 30, 171, 244, 35, 61, 30, 41, 23, 239, 32, 1, 213, 201,
+			227, 34, 105, 143, 139, 182, 20, 224, 233, 105, 79, 79, 182, 26,
+			204, 228, 25, 70, 210, 45, 154, 153, 218, 91, 97, 120, 237, 26,
+			231, 34, 63, 86, 120, 157, 71, 59, 48, 22, 201, 94, 71, 90,
+			207, 50, 95, 121, 79, 52, 155, 183, 79, 128, 168, 80, 80, 230,
+			138, 96, 195, 36, 115, 195, 64, 144, 240, 168, 37, 247, 107, 220,
+			160, 103, 159, 34, 108, 130, 65, 235, 250, 190, 138, 129, 197, 84,
+			44, 232, 72, 101, 17, 111, 187, 153, 179, 144, 21, 198, 30, 234,
+			70, 48, 12, 160, 55, 0, 171, 69, 220, 109, 46, 196, 110, 139,
+			235, 244, 235, 36, 243, 49, 47, 219, 158, 204, 165, 10, 162, 193,
+			23, 48, 238, 38, 81, 17, 121, 234, 99, 128, 13, 133, 49, 244,
+			93, 184, 187, 244, 177, 9, 241, 65, 100, 231, 70, 55, 18, 231,
+			201, 112, 205, 241, 69, 74, 146, 94, 132, 192, 244, 94, 208, 229,
+			68, 156, 59, 193, 156, 28, 140, 171, 236, 194, 146, 45, 43, 164,
+			231, 172, 127, 191, 181, 186, 223, 182, 198, 43, 188, 117, 138, 15,
+			165, 85, 137, 227, 67, 4, 151, 178, 167, 247, 122, 15, 104, 136,
+			168, 120, 47, 46, 99, 151, 128, 45, 106, 113, 77, 204, 91, 239,
+			105, 222, 156, 157, 83, 138, 86, 207, 236, 38, 248, 237, 136, 39,
+			221, 72, 50, 36, 38, 27, 145, 118, 114, 239, 84, 220, 113, 99,
+			134, 119, 68, 225, 20, 232, 105, 89, 198, 109, 31, 112, 232, 176,
+			27, 237, 233, 195, 8, 161, 10, 116, 59, 0, 39, 90, 14, 242,
+			48, 89, 40, 98, 252, 196, 252, 246, 2, 145, 112, 70, 46, 83,
+			120, 5, 24, 102, 167, 2, 194, 148, 65, 206, 115, 87, 107, 132,
+			157, 110, 212, 9, 69, 244, 5, 16, 134, 168, 153, 1, 234, 70,
+			208, 191, 54, 74, 135, 41, 146, 59, 126, 65, 122, 19, 237, 195,
+			214, 217, 91, 18, 121, 223, 128, 151, 100, 41, 174, 182, 18, 50,
+			209, 88, 153, 177, 81, 210, 178, 183, 25, 136, 90, 222, 14, 54,
+			47, 78, 48, 204, 203, 102, 120, 120, 209, 92, 79, 83, 208, 213,
+			56, 143, 1, 203, 243, 228, 133, 170, 245, 202, 38, 37, 207, 68,
+			6, 155, 30, 7, 218, 14, 232, 194, 91, 156, 7, 146, 226, 122,
+			61, 183, 108, 92, 113, 52, 232, 0, 168, 21, 85, 203, 0, 80,
+			166, 4, 28, 65, 183, 194, 239, 218, 183, 169, 229, 221, 202, 3,
+			120, 104, 94, 129, 131, 0, 158, 156, 35, 159, 51, 112, 45, 179,
+			169, 253, 135, 182, 121, 103, 241, 191, 100, 195, 144, 64, 102, 189,
+			100, 222, 90, 229, 39, 143, 191, 49, 95, 173, 204, 1, 120, 83,
+			161, 71, 50, 123, 171, 232, 185, 45, 186, 170, 65, 7, 64, 77,
+			68, 219, 0, 176, 160, 108, 75, 219, 2, 80, 122, 106, 71, 48,
+			176, 250, 15, 109, 233, 169, 29, 193, 192, 234, 63, 180, 231, 207,
+			144, 7, 145, 134, 14, 181, 255, 200, 54, 203, 197, 59, 191, 254,
+			11, 82, 4, 62, 199, 70, 12, 26, 68, 132, 186, 105, 142, 1,
+			160, 30, 95, 199, 2, 240, 182, 67, 10, 204, 3, 88, 60, 173,
+			192, 65, 0, 79, 205, 163, 215, 102, 4, 52, 191, 207, 127, 11,
+			189, 54, 35, 24, 220, 250, 249, 180, 169, 57, 252, 192, 176, 162,
+			83, 206, 0, 80, 122, 109, 70, 48, 184, 245, 243, 202, 107, 51,
+			226, 80, 251, 11, 47, 145, 215, 102, 196, 25, 0, 228, 210, 107,
+			51, 130, 94, 155, 47, 40, 175, 205, 8, 122, 109, 190, 96, 223,
+			50, 77, 142, 64, 59, 134, 168, 253, 69, 219, 28, 45, 141, 203,
+			139, 71, 154, 236, 73, 84, 194, 4, 166, 161, 1, 120, 44, 189,
+			96, 35, 232, 106, 207, 130, 166, 4, 71, 136, 105, 143, 210, 220,
+			243, 246, 192, 95, 73, 255, 207, 168, 65, 237, 231, 237, 252, 20,
+			82, 126, 20, 20, 225, 63, 255, 22, 82, 126, 20, 93, 37, 127,
+			174, 40, 63, 138, 174, 146, 63, 87, 148, 31, 69, 117, 250, 207,
+			21, 229, 71, 209, 85, 242, 231, 138, 242, 163, 14, 181, 191, 252,
+			18, 81, 126, 20, 40, 255, 101, 69, 249, 81, 164, 252, 151, 21,
+			229, 71, 145, 242, 95, 86, 254, 178, 49, 154, 251, 107, 123, 224,
+			251, 28, 65, 175, 49, 131, 218, 127, 109, 231, 39, 201, 19, 196,
+			182, 199, 128, 94, 127, 107, 155, 172, 88, 23, 155, 214, 189, 145,
+			23, 106, 15, 27, 111, 239, 102, 109, 113, 75, 87, 234, 27, 19,
+			55, 37, 224, 141, 78, 73, 122, 172, 131, 168, 100, 148, 216, 150,
+			49, 36, 224, 223, 42, 2, 142, 225, 238, 225, 223, 170, 89, 54,
+			134, 4, 252, 91, 187, 112, 88, 129, 22, 128, 71, 143, 145, 207,
+			24, 216, 60, 131, 218, 111, 114, 204, 59, 139, 191, 149, 202, 73,
+			153, 66, 230, 37, 220, 212, 18, 231, 176, 94, 90, 41, 137, 123,
+			187, 154, 70, 134, 141, 253, 212, 160, 3, 160, 166, 145, 129, 84,
+			144, 66, 114, 12, 93, 34, 111, 114, 164, 144, 28, 195, 157, 193,
+			55, 57, 82, 72, 142, 225, 206, 224, 155, 156, 249, 51, 232, 140,
+			30, 27, 162, 246, 155, 157, 23, 112, 70, 143, 193, 188, 123, 179,
+			35, 39, 218, 24, 206, 187, 44, 104, 74, 112, 13, 71, 195, 164,
+			246, 91, 156, 111, 221, 228, 26, 67, 139, 249, 45, 105, 191, 65,
+			110, 190, 197, 25, 86, 61, 131, 209, 127, 139, 35, 39, 215, 24,
+			90, 204, 111, 113, 228, 228, 26, 115, 168, 253, 54, 231, 165, 153,
+			92, 99, 48, 185, 222, 230, 200, 201, 53, 134, 147, 235, 109, 142,
+			156, 92, 99, 56, 185, 222, 230, 200, 201, 53, 78, 115, 111, 119,
+			6, 222, 39, 39, 215, 184, 65, 237, 183, 59, 249, 105, 242, 151,
+			192, 190, 227, 152, 141, 7, 216, 247, 115, 125, 236, 43, 172, 164,
+			151, 156, 137, 197, 119, 94, 234, 205, 217, 76, 174, 39, 57, 176,
+			227, 34, 241, 144, 26, 216, 113, 145, 120, 72, 49, 244, 184, 72,
+			60, 164, 24, 122, 92, 36, 30, 82, 12, 61, 142, 14, 230, 119,
+			41, 134, 30, 71, 7, 243, 187, 128, 161, 215, 144, 164, 6, 181,
+			223, 243, 45, 228, 193, 113, 156, 123, 239, 73, 155, 106, 228, 0,
+			28, 86, 141, 49, 240, 123, 146, 7, 199, 113, 238, 189, 71, 241,
+			224, 184, 67, 237, 247, 190, 68, 60, 56, 14, 60, 248, 94, 197,
+			131, 227, 200, 131, 239, 85, 60, 56, 142, 60, 248, 94, 197, 131,
+			5, 154, 123, 191, 51, 240, 175, 37, 15, 22, 12, 106, 191, 223,
+			201, 223, 74, 254, 139, 69, 108, 187, 0, 60, 248, 172, 99, 158,
+			203, 134, 6, 164, 199, 75, 95, 66, 6, 148, 31, 121, 169, 185,
+			79, 29, 204, 168, 144, 115, 159, 51, 208, 178, 60, 207, 68, 242,
+			32, 157, 12, 226, 78, 157, 5, 226, 174, 115, 42, 241, 80, 154,
+			9, 94, 223, 136, 173, 85, 199, 250, 149, 37, 194, 24, 107, 69,
+			110, 155, 239, 134, 209, 181, 10, 99, 143, 114, 230, 118, 66, 63,
+			220, 6, 78, 194, 139, 65, 66, 55, 106, 74, 171, 43, 206, 220,
+			96, 17, 178, 176, 27, 197, 220, 191, 206, 99, 185, 229, 203, 216,
+			46, 23, 231, 108, 84, 6, 71, 225, 184, 192, 19, 35, 152, 155,
+			117, 11, 207, 167, 64, 181, 38, 111, 120, 210, 245, 160, 54, 122,
+			212, 21, 170, 128, 72, 222, 162, 42, 89, 183, 128, 179, 236, 89,
+			197, 186, 5, 156, 101, 207, 170, 89, 86, 192, 89, 246, 172, 154,
+			101, 5, 156, 101, 207, 58, 50, 174, 191, 128, 179, 236, 89, 71,
+			198, 245, 23, 112, 150, 61, 235, 156, 62, 139, 179, 172, 0, 179,
+			236, 35, 223, 194, 89, 86, 192, 89, 246, 145, 180, 169, 48, 203,
+			62, 162, 102, 89, 1, 103, 217, 71, 212, 44, 43, 224, 44, 251,
+			136, 154, 101, 5, 135, 218, 207, 189, 68, 179, 172, 0, 179, 236,
+			57, 53, 203, 10, 56, 203, 158, 83, 179, 172, 128, 179, 236, 57,
+			53, 203, 38, 104, 238, 163, 206, 192, 127, 144, 179, 108, 194, 160,
+			246, 71, 157, 252, 45, 228, 183, 96, 150, 77, 192, 44, 251, 24,
+			204, 178, 255, 154, 13, 192, 65, 255, 206, 75, 28, 127, 3, 223,
+			120, 233, 195, 111, 228, 33, 206, 255, 191, 77, 177, 9, 156, 98,
+			31, 83, 124, 59, 129, 83, 236, 99, 106, 138, 77, 224, 20, 251,
+			152, 154, 98, 19, 56, 197, 62, 166, 166, 216, 4, 78, 177, 143,
+			169, 41, 54, 129, 83, 236, 99, 48, 197, 190, 8, 202, 193, 132,
+			61, 64, 115, 191, 232, 152, 255, 135, 99, 245, 70, 109, 73, 167,
+			96, 147, 47, 136, 83, 204, 11, 232, 153, 157, 13, 35, 225, 170,
+			243, 2, 246, 240, 198, 198, 21, 152, 147, 190, 27, 52, 248, 156,
+			24, 252, 38, 111, 119, 194, 132, 7, 48, 172, 97, 196, 2, 225,
+			79, 121, 80, 212, 197, 155, 120, 241, 88, 92, 191, 235, 37, 117,
+			183, 93, 170, 110, 0, 115, 108, 137, 3, 200, 110, 75, 220, 140,
+			15, 195, 46, 226, 59, 175, 92, 205, 60, 79, 63, 167, 189, 127,
+			202, 165, 221, 183, 71, 115, 101, 109, 125, 67, 17, 19, 55, 131,
+			127, 209, 25, 188, 13, 247, 101, 39, 196, 102, 240, 47, 57, 246,
+			17, 116, 249, 79, 200, 189, 223, 95, 114, 228, 149, 25, 19, 114,
+			239, 247, 151, 156, 226, 97, 114, 82, 190, 97, 80, 251, 223, 58,
+			246, 116, 105, 74, 68, 138, 240, 56, 211, 22, 162, 95, 51, 68,
+			181, 201, 180, 192, 132, 130, 91, 111, 35, 247, 73, 60, 38, 181,
+			127, 217, 177, 39, 75, 51, 89, 210, 137, 164, 114, 42, 71, 23,
+			102, 77, 16, 99, 16, 167, 168, 161, 5, 191, 236, 200, 59, 95,
+			38, 228, 214, 241, 47, 59, 120, 181, 16, 8, 2, 131, 230, 126,
+			197, 49, 255, 157, 115, 135, 28, 117, 16, 124, 191, 146, 50, 16,
+			8, 190, 95, 113, 100, 186, 195, 9, 108, 232, 175, 56, 71, 22,
+			20, 104, 1, 40, 147, 3, 76, 160, 106, 255, 239, 156, 220, 41,
+			5, 14, 2, 56, 113, 18, 101, 244, 4, 124, 247, 19, 223, 66,
+			25, 61, 129, 218, 248, 39, 210, 166, 130, 54, 254, 9, 37, 163,
+			39, 176, 227, 159, 80, 50, 122, 2, 181, 241, 79, 40, 25, 61,
+			225, 80, 251, 147, 47, 145, 140, 158, 0, 25, 253, 73, 37, 163,
+			39, 80, 70, 127, 82, 201, 232, 9, 148, 209, 159, 4, 25, 253,
+			143, 45, 98, 218, 148, 230, 126, 219, 25, 248, 127, 29, 163, 248,
+			85, 147, 45, 106, 23, 159, 222, 72, 5, 169, 224, 106, 11, 55,
+			37, 155, 118, 230, 107, 42, 201, 35, 105, 34, 224, 215, 237, 116,
+			184, 43, 206, 219, 170, 94, 200, 20, 91, 34, 217, 179, 58, 166,
+			171, 67, 148, 206, 159, 191, 34, 19, 149, 137, 156, 36, 217, 244,
+			181, 97, 232, 171, 212, 135, 177, 148, 109, 184, 135, 131, 185, 189,
+			160, 129, 203, 153, 60, 229, 120, 120, 44, 174, 244, 28, 99, 237,
+			107, 130, 23, 244, 100, 54, 23, 111, 200, 27, 200, 133, 63, 91,
+			180, 47, 69, 123, 254, 188, 68, 49, 59, 39, 196, 69, 39, 10,
+			69, 146, 254, 190, 106, 75, 97, 103, 111, 35, 156, 157, 155, 147,
+			27, 89, 152, 38, 2, 39, 199, 213, 108, 110, 52, 157, 64, 77,
+			101, 95, 19, 121, 131, 168, 65, 237, 223, 118, 242, 135, 201, 39,
+			76, 98, 219, 212, 26, 160, 185, 223, 117, 204, 255, 199, 177, 138,
+			255, 70, 132, 85, 100, 79, 121, 247, 36, 91, 75, 55, 142, 48,
+			159, 158, 204, 45, 161, 71, 81, 36, 253, 220, 150, 39, 204, 9,
+			115, 89, 51, 76, 22, 84, 190, 149, 166, 10, 238, 245, 226, 205,
+			52, 169, 132, 204, 145, 207, 188, 86, 43, 243, 118, 22, 101, 144,
+			201, 184, 198, 102, 155, 60, 8, 19, 149, 54, 66, 92, 106, 2,
+			67, 213, 195, 3, 113, 135, 55, 226, 254, 16, 184, 185, 10, 97,
+			213, 202, 118, 165, 252, 247, 217, 119, 202, 219, 180, 49, 64, 226,
+			181, 101, 246, 157, 165, 45, 55, 170, 108, 185, 79, 151, 202, 216,
+			24, 44, 122, 93, 247, 41, 93, 133, 189, 33, 211, 34, 34, 110,
+			225, 158, 149, 239, 204, 85, 160, 166, 156, 172, 20, 211, 17, 255,
+			174, 67, 196, 53, 80, 84, 164, 35, 254, 61, 199, 46, 161, 92,
+			162, 50, 1, 241, 239, 57, 50, 61, 48, 149, 9, 136, 127, 207,
+			25, 157, 76, 11, 12, 40, 152, 186, 61, 45, 176, 160, 128, 29,
+			215, 56, 13, 106, 255, 190, 99, 159, 208, 21, 64, 152, 253, 126,
+			22, 167, 225, 64, 193, 232, 68, 90, 128, 175, 208, 163, 105, 129,
+			5, 5, 199, 75, 56, 151, 41, 180, 242, 211, 142, 41, 146, 100,
+			82, 108, 227, 167, 149, 196, 161, 232, 92, 251, 180, 51, 60, 169,
+			64, 3, 192, 169, 105, 5, 90, 0, 30, 62, 34, 238, 19, 162,
+			208, 184, 207, 58, 230, 76, 241, 109, 70, 230, 218, 139, 23, 224,
+			166, 50, 140, 212, 238, 142, 155, 32, 23, 99, 164, 2, 106, 96,
+			225, 53, 14, 19, 62, 34, 176, 20, 136, 124, 149, 152, 53, 208,
+			141, 213, 45, 245, 122, 19, 165, 42, 79, 44, 200, 27, 134, 196,
+			228, 149, 17, 140, 242, 194, 34, 213, 23, 32, 213, 103, 211, 174,
+			1, 161, 62, 235, 200, 96, 0, 138, 100, 250, 172, 35, 143, 197,
+			81, 36, 210, 103, 157, 83, 119, 72, 34, 153, 212, 254, 35, 199,
+			156, 147, 15, 65, 44, 255, 81, 138, 9, 253, 222, 41, 38, 32,
+			195, 31, 57, 244, 164, 2, 45, 0, 103, 102, 37, 38, 139, 218,
+			159, 115, 100, 234, 93, 138, 27, 26, 159, 75, 49, 89, 14, 128,
+			50, 236, 139, 226, 134, 198, 231, 156, 137, 19, 10, 196, 119, 239,
+			152, 145, 152, 108, 106, 255, 55, 199, 84, 15, 109, 1, 42, 76,
+			182, 3, 160, 110, 147, 109, 0, 40, 79, 59, 81, 244, 234, 255,
+			183, 148, 5, 28, 106, 255, 177, 99, 170, 174, 59, 54, 130, 10,
+			147, 131, 79, 117, 155, 28, 3, 192, 9, 197, 47, 142, 5, 160,
+			76, 170, 74, 209, 205, 238, 200, 60, 186, 84, 248, 200, 83, 76,
+			57, 7, 64, 221, 38, 244, 145, 59, 244, 184, 2, 45, 0, 79,
+			158, 34, 191, 99, 16, 211, 158, 164, 185, 231, 157, 129, 63, 203,
+			25, 197, 39, 89, 53, 104, 184, 157, 88, 38, 195, 188, 185, 251,
+			181, 69, 102, 167, 40, 108, 171, 120, 28, 194, 30, 242, 124, 222,
+			151, 189, 150, 237, 186, 153, 228, 30, 21, 114, 238, 137, 111, 101,
+			250, 209, 222, 219, 190, 177, 225, 66, 10, 79, 26, 212, 126, 222,
+			201, 223, 74, 190, 123, 130, 216, 246, 36, 204, 192, 31, 201, 153,
+			199, 139, 207, 23, 216, 34, 91, 9, 101, 206, 69, 47, 77, 213,
+			234, 178, 142, 199, 197, 222, 117, 47, 70, 230, 246, 102, 231, 130,
+			190, 18, 214, 8, 163, 136, 199, 157, 48, 16, 193, 110, 110, 118,
+			239, 47, 205, 128, 170, 207, 90, 244, 94, 13, 46, 146, 254, 201,
+			171, 27, 210, 187, 36, 146, 144, 213, 150, 171, 120, 175, 80, 83,
+			222, 207, 195, 163, 184, 220, 119, 52, 44, 61, 44, 44, 179, 102,
+			121, 109, 207, 119, 35, 162, 47, 21, 151, 23, 67, 97, 246, 189,
+			50, 139, 221, 61, 48, 0, 196, 57, 31, 209, 5, 29, 157, 126,
+			195, 51, 71, 64, 86, 153, 217, 40, 12, 117, 168, 249, 27, 8,
+			91, 225, 120, 60, 42, 12, 175, 49, 55, 17, 249, 90, 211, 184,
+			208, 180, 223, 136, 253, 133, 80, 61, 46, 227, 214, 31, 127, 92,
+			255, 129, 255, 143, 63, 14, 15, 93, 249, 112, 171, 129, 127, 154,
+			156, 177, 22, 99, 219, 59, 30, 1, 115, 73, 103, 30, 213, 25,
+			93, 152, 47, 199, 83, 132, 240, 199, 29, 55, 96, 12, 179, 183,
+			176, 222, 127, 217, 85, 134, 177, 239, 116, 203, 222, 28, 99, 223,
+			201, 238, 46, 179, 179, 101, 118, 174, 204, 206, 178, 215, 98, 61,
+			16, 172, 187, 59, 161, 191, 191, 99, 21, 249, 226, 86, 223, 139,
+			101, 118, 55, 188, 11, 47, 250, 238, 22, 247, 217, 172, 234, 253,
+			156, 120, 165, 81, 110, 238, 123, 229, 30, 245, 138, 184, 146, 76,
+			144, 73, 214, 231, 229, 214, 190, 250, 119, 170, 250, 34, 79, 101,
+			43, 12, 101, 229, 237, 242, 206, 190, 202, 119, 233, 202, 34, 197,
+			227, 236, 157, 115, 234, 194, 1, 32, 211, 2, 91, 212, 100, 147,
+			49, 0, 58, 161, 180, 14, 235, 148, 1, 37, 73, 204, 253, 150,
+			188, 50, 77, 238, 151, 99, 222, 50, 150, 101, 122, 113, 157, 148,
+			76, 112, 234, 37, 115, 153, 99, 70, 93, 21, 176, 35, 242, 109,
+			97, 136, 123, 216, 82, 17, 155, 177, 200, 253, 203, 24, 24, 193,
+			34, 20, 133, 7, 13, 63, 148, 81, 0, 58, 92, 83, 156, 61,
+			18, 26, 76, 133, 245, 50, 57, 198, 180, 37, 94, 148, 102, 200,
+			196, 104, 206, 198, 53, 54, 219, 9, 227, 216, 219, 242, 247, 178,
+			247, 90, 233, 80, 143, 84, 243, 201, 100, 45, 22, 106, 31, 102,
+			173, 20, 135, 227, 100, 200, 132, 38, 215, 238, 14, 152, 142, 200,
+			95, 72, 53, 189, 17, 84, 74, 53, 252, 146, 166, 34, 90, 252,
+			58, 228, 17, 179, 212, 4, 130, 90, 21, 24, 134, 203, 170, 45,
+			154, 137, 83, 91, 76, 159, 219, 130, 111, 41, 130, 138, 152, 198,
+			88, 5, 53, 10, 234, 100, 232, 151, 189, 104, 0, 239, 195, 234,
+			68, 104, 161, 194, 135, 69, 130, 94, 221, 125, 204, 167, 36, 179,
+			147, 179, 118, 24, 163, 83, 33, 220, 186, 238, 133, 221, 88, 17,
+			87, 221, 40, 39, 250, 214, 44, 73, 186, 186, 219, 174, 23, 232,
+			52, 167, 42, 51, 110, 54, 169, 107, 118, 24, 122, 175, 67, 136,
+			27, 97, 135, 151, 69, 216, 46, 6, 41, 232, 180, 177, 7, 244,
+			186, 151, 85, 103, 98, 49, 189, 85, 208, 143, 56, 118, 129, 185,
+			74, 37, 87, 121, 73, 44, 21, 87, 89, 87, 240, 138, 104, 145,
+			100, 151, 76, 127, 56, 47, 137, 112, 165, 30, 94, 72, 9, 168,
+			108, 32, 17, 156, 5, 88, 182, 248, 182, 23, 32, 27, 73, 173,
+			171, 159, 50, 226, 88, 104, 188, 227, 70, 194, 180, 232, 75, 59,
+			172, 130, 120, 68, 186, 84, 124, 7, 59, 249, 136, 136, 53, 17,
+			49, 51, 238, 65, 61, 206, 118, 51, 14, 219, 42, 155, 100, 95,
+			77, 192, 172, 13, 187, 54, 119, 245, 29, 33, 2, 5, 24, 77,
+			60, 104, 186, 7, 76, 34, 86, 194, 187, 105, 75, 210, 130, 69,
+			33, 137, 151, 80, 186, 66, 66, 65, 207, 50, 89, 164, 245, 204,
+			236, 185, 217, 34, 205, 207, 152, 114, 52, 32, 74, 23, 76, 79,
+			92, 204, 165, 242, 67, 200, 120, 98, 64, 42, 19, 155, 121, 17,
+			90, 148, 97, 144, 6, 19, 234, 107, 49, 216, 2, 91, 74, 83,
+			22, 137, 155, 87, 240, 4, 128, 212, 123, 51, 211, 72, 106, 168,
+			157, 40, 220, 114, 183, 68, 240, 96, 147, 199, 222, 118, 128, 126,
+			48, 204, 59, 140, 110, 66, 150, 224, 124, 86, 84, 82, 158, 3,
+			145, 72, 35, 113, 131, 102, 25, 148, 98, 12, 98, 23, 225, 177,
+			97, 43, 243, 149, 134, 72, 140, 196, 196, 197, 24, 141, 48, 106,
+			102, 146, 54, 226, 57, 4, 169, 28, 79, 162, 222, 255, 35, 57,
+			83, 131, 57, 0, 165, 222, 63, 137, 122, 255, 143, 228, 166, 142,
+			40, 208, 2, 240, 24, 67, 7, 203, 36, 88, 145, 31, 200, 153,
+			207, 231, 68, 44, 236, 36, 26, 66, 31, 200, 17, 74, 222, 56,
+			72, 114, 0, 131, 134, 243, 51, 57, 187, 92, 252, 82, 46, 155,
+			129, 94, 230, 200, 118, 163, 68, 177, 235, 141, 116, 52, 117, 0,
+			88, 222, 69, 67, 116, 31, 49, 61, 118, 38, 196, 94, 250, 78,
+			51, 158, 80, 121, 53, 148, 16, 106, 50, 159, 23, 70, 108, 185,
+			66, 74, 130, 146, 40, 142, 219, 70, 97, 152, 28, 216, 2, 149,
+			114, 5, 36, 146, 204, 12, 151, 244, 36, 147, 87, 211, 56, 51,
+			137, 189, 24, 209, 227, 162, 143, 139, 223, 93, 184, 248, 221, 135,
+			43, 37, 73, 101, 243, 121, 33, 130, 125, 94, 81, 71, 169, 97,
+			28, 103, 239, 154, 99, 236, 204, 25, 124, 79, 29, 164, 171, 96,
+			175, 102, 239, 155, 211, 42, 195, 153, 51, 136, 82, 87, 128, 165,
+			119, 118, 46, 163, 83, 156, 57, 195, 238, 76, 163, 223, 212, 252,
+			61, 160, 139, 61, 31, 23, 7, 202, 179, 36, 188, 27, 91, 169,
+			151, 222, 126, 250, 244, 188, 252, 0, 187, 251, 2, 193, 217, 210,
+			255, 13, 129, 114, 31, 242, 115, 189, 200, 15, 186, 101, 130, 169,
+			211, 127, 231, 36, 234, 3, 175, 162, 64, 205, 99, 31, 250, 59,
+			15, 212, 247, 176, 174, 60, 77, 152, 10, 5, 113, 64, 27, 217,
+			34, 189, 139, 82, 47, 173, 97, 122, 247, 152, 116, 103, 212, 90,
+			160, 190, 202, 224, 25, 193, 68, 190, 27, 39, 138, 25, 247, 13,
+			62, 140, 188, 102, 141, 190, 245, 185, 87, 167, 75, 133, 252, 172,
+			206, 18, 168, 133, 59, 81, 211, 69, 168, 115, 74, 94, 225, 65,
+			11, 181, 5, 215, 246, 26, 161, 31, 6, 115, 50, 156, 123, 82,
+			186, 31, 126, 38, 39, 93, 5, 147, 210, 253, 240, 51, 57, 121,
+			59, 209, 164, 116, 63, 252, 76, 110, 242, 182, 180, 192, 130, 130,
+			226, 225, 180, 32, 15, 5, 71, 78, 147, 2, 201, 203, 2, 19,
+			74, 110, 159, 39, 191, 97, 202, 201, 110, 80, 251, 227, 48, 217,
+			127, 193, 84, 71, 15, 119, 240, 42, 2, 97, 168, 39, 59, 17,
+			231, 34, 121, 124, 55, 210, 122, 214, 121, 153, 247, 217, 247, 2,
+			176, 11, 240, 119, 35, 244, 187, 237, 160, 76, 24, 44, 213, 240,
+			32, 85, 92, 203, 153, 192, 77, 55, 142, 187, 109, 222, 20, 203,
+			178, 27, 103, 16, 205, 149, 241, 85, 129, 71, 223, 115, 224, 70,
+			250, 244, 144, 23, 136, 68, 169, 98, 169, 16, 180, 199, 67, 59,
+			242, 60, 80, 99, 175, 194, 50, 161, 177, 128, 83, 240, 159, 64,
+			169, 55, 64, 0, 229, 211, 60, 10, 23, 132, 91, 31, 52, 15,
+			29, 186, 188, 23, 118, 197, 50, 177, 43, 207, 179, 187, 205, 38,
+			97, 119, 226, 41, 36, 16, 92, 114, 31, 164, 233, 197, 29, 223,
+			221, 243, 212, 197, 144, 93, 113, 154, 82, 209, 221, 176, 145, 168,
+			233, 216, 25, 14, 20, 100, 198, 206, 64, 178, 103, 198, 206, 176,
+			160, 32, 51, 118, 70, 30, 10, 50, 99, 103, 192, 216, 125, 28,
+			198, 238, 67, 35, 114, 236, 76, 106, 127, 49, 103, 207, 21, 127,
+			96, 68, 103, 52, 95, 71, 139, 19, 22, 182, 90, 208, 10, 123,
+			93, 126, 58, 175, 105, 134, 99, 85, 254, 99, 23, 179, 60, 237,
+			129, 57, 218, 150, 186, 52, 234, 43, 94, 186, 249, 131, 139, 54,
+			94, 217, 40, 228, 105, 202, 245, 210, 106, 199, 23, 84, 244, 173,
+			186, 151, 67, 49, 124, 118, 105, 39, 132, 45, 98, 42, 85, 185,
+			4, 162, 147, 123, 255, 119, 195, 0, 195, 101, 121, 163, 139, 7,
+			194, 160, 90, 44, 50, 88, 178, 32, 20, 60, 69, 132, 251, 169,
+			239, 45, 177, 12, 235, 250, 98, 83, 47, 145, 158, 102, 188, 95,
+			83, 91, 2, 248, 81, 104, 144, 207, 93, 208, 39, 54, 155, 92,
+			180, 123, 83, 55, 8, 17, 92, 227, 188, 3, 139, 159, 187, 29,
+			185, 157, 29, 108, 182, 174, 128, 236, 38, 26, 64, 20, 177, 102,
+			183, 186, 9, 234, 77, 141, 48, 8, 68, 56, 121, 18, 206, 9,
+			31, 183, 8, 5, 87, 179, 169, 34, 150, 68, 141, 27, 99, 252,
+			149, 39, 118, 107, 79, 36, 225, 233, 239, 76, 152, 82, 44, 93,
+			76, 133, 170, 144, 90, 11, 250, 0, 242, 26, 30, 222, 216, 73,
+			95, 145, 231, 43, 178, 201, 81, 46, 232, 135, 109, 55, 186, 6,
+			243, 68, 120, 192, 207, 156, 153, 19, 102, 85, 140, 215, 82, 114,
+			212, 255, 165, 194, 39, 212, 84, 69, 135, 178, 162, 33, 240, 67,
+			34, 19, 46, 33, 211, 4, 204, 141, 19, 30, 121, 241, 181, 244,
+			178, 91, 141, 110, 191, 196, 68, 147, 14, 211, 114, 2, 99, 132,
+			233, 57, 62, 225, 32, 136, 226, 164, 66, 216, 42, 223, 69, 154,
+			32, 231, 202, 99, 155, 233, 145, 79, 188, 55, 73, 220, 198, 161,
+			238, 14, 232, 89, 88, 48, 69, 136, 246, 35, 224, 218, 187, 36,
+			187, 159, 101, 220, 86, 24, 162, 93, 124, 131, 199, 91, 110, 84,
+			57, 0, 237, 150, 27, 137, 213, 239, 160, 181, 108, 203, 125, 154,
+			61, 192, 238, 186, 240, 130, 104, 159, 86, 95, 93, 12, 164, 102,
+			14, 148, 216, 87, 231, 5, 112, 188, 174, 251, 148, 196, 241, 98,
+			152, 84, 205, 204, 189, 170, 221, 45, 159, 67, 185, 80, 15, 36,
+			130, 101, 57, 49, 52, 159, 136, 43, 112, 162, 237, 204, 37, 161,
+			192, 240, 138, 9, 194, 136, 37, 145, 235, 225, 1, 2, 197, 34,
+			18, 149, 248, 42, 83, 239, 103, 239, 233, 140, 132, 40, 218, 242,
+			221, 224, 154, 96, 122, 53, 27, 228, 129, 73, 161, 1, 34, 26,
+			176, 40, 42, 47, 222, 188, 116, 106, 177, 115, 149, 3, 199, 68,
+			84, 123, 128, 221, 35, 70, 101, 158, 93, 204, 50, 182, 166, 22,
+			170, 110, 243, 34, 67, 59, 118, 155, 173, 200, 190, 42, 246, 142,
+			101, 21, 197, 228, 82, 217, 168, 176, 249, 51, 47, 136, 89, 90,
+			17, 108, 158, 109, 71, 152, 175, 80, 190, 208, 199, 88, 226, 33,
+			123, 128, 221, 171, 71, 69, 198, 39, 176, 102, 95, 247, 227, 204,
+			114, 132, 201, 62, 178, 203, 17, 166, 251, 200, 201, 157, 12, 44,
+			48, 160, 96, 234, 100, 90, 96, 65, 193, 204, 44, 238, 100, 64,
+			129, 69, 237, 63, 205, 217, 243, 186, 130, 101, 99, 65, 138, 211,
+			114, 160, 32, 131, 211, 50, 160, 96, 234, 84, 90, 128, 56, 102,
+			231, 52, 78, 155, 218, 95, 202, 217, 231, 116, 5, 91, 20, 164,
+			56, 109, 7, 10, 50, 56, 109, 3, 10, 166, 22, 210, 2, 11,
+			10, 206, 222, 73, 190, 96, 16, 211, 158, 162, 185, 191, 202, 13,
+			124, 223, 160, 81, 252, 61, 35, 115, 161, 155, 144, 138, 190, 48,
+			179, 118, 188, 14, 219, 226, 201, 46, 231, 65, 223, 177, 34, 97,
+			111, 39, 113, 191, 123, 90, 221, 23, 176, 152, 166, 31, 215, 235,
+			107, 54, 137, 72, 28, 135, 13, 207, 213, 123, 94, 250, 154, 20,
+			253, 21, 146, 245, 119, 167, 155, 229, 42, 221, 63, 106, 152, 152,
+			148, 29, 88, 44, 141, 18, 150, 47, 245, 164, 137, 16, 174, 233,
+			41, 131, 218, 127, 149, 203, 31, 34, 79, 17, 219, 158, 2, 187,
+			237, 171, 57, 243, 84, 241, 73, 182, 24, 176, 69, 29, 238, 162,
+			150, 160, 88, 152, 250, 232, 3, 0, 149, 148, 63, 133, 107, 70,
+			31, 17, 208, 37, 167, 214, 20, 162, 252, 32, 42, 159, 80, 176,
+			221, 123, 111, 5, 218, 144, 83, 168, 172, 126, 85, 217, 163, 83,
+			104, 143, 126, 53, 55, 124, 139, 2, 13, 0, 111, 101, 10, 180,
+			0, 60, 113, 18, 237, 209, 41, 176, 71, 191, 150, 51, 223, 54,
+			40, 236, 209, 41, 180, 71, 191, 150, 35, 83, 228, 127, 54, 72,
+			14, 96, 232, 215, 51, 131, 118, 185, 248, 93, 89, 115, 20, 99,
+			52, 123, 87, 190, 254, 109, 133, 158, 171, 147, 211, 107, 167, 48,
+			190, 88, 120, 209, 113, 89, 86, 78, 34, 183, 95, 123, 170, 40,
+			239, 126, 69, 248, 143, 144, 235, 166, 164, 110, 254, 204, 160, 100,
+			212, 41, 169, 155, 63, 51, 40, 245, 187, 41, 169, 155, 63, 51,
+			40, 245, 187, 41, 169, 155, 63, 51, 40, 245, 187, 41, 169, 155,
+			63, 51, 40, 245, 187, 41, 165, 155, 63, 51, 120, 251, 60, 89,
+			147, 253, 54, 168, 253, 198, 65, 251, 100, 241, 193, 254, 126, 35,
+			15, 96, 130, 102, 97, 155, 72, 21, 235, 224, 254, 103, 218, 13,
+			122, 233, 27, 179, 237, 6, 189, 244, 141, 131, 114, 130, 77, 73,
+			189, 244, 141, 131, 83, 199, 210, 2, 11, 10, 74, 39, 200, 174,
+			108, 148, 73, 237, 55, 15, 218, 183, 23, 183, 251, 27, 133, 154,
+			188, 88, 189, 91, 49, 199, 65, 217, 218, 75, 210, 251, 75, 123,
+			249, 76, 38, 165, 198, 105, 153, 198, 191, 103, 54, 16, 197, 62,
+			124, 166, 241, 32, 46, 222, 156, 109, 60, 72, 177, 55, 103, 137,
+			14, 4, 123, 243, 160, 188, 16, 118, 74, 74, 177, 55, 15, 30,
+			62, 66, 62, 165, 88, 201, 162, 246, 91, 7, 237, 195, 197, 95,
+			51, 246, 241, 146, 12, 43, 187, 153, 198, 203, 3, 148, 47, 208,
+			120, 196, 34, 242, 180, 240, 64, 193, 153, 157, 78, 144, 11, 29,
+			55, 78, 50, 102, 104, 196, 125, 126, 29, 47, 213, 222, 75, 56,
+			155, 149, 247, 181, 137, 212, 2, 202, 132, 196, 57, 251, 0, 162,
+			92, 16, 250, 210, 92, 134, 66, 120, 195, 107, 150, 66, 32, 147,
+			223, 154, 165, 16, 222, 242, 58, 40, 47, 180, 157, 146, 50, 249,
+			173, 131, 135, 138, 228, 131, 71, 201, 49, 145, 131, 252, 140, 219,
+			241, 206, 224, 68, 217, 84, 7, 199, 5, 35, 81, 34, 147, 148,
+			187, 29, 175, 40, 19, 150, 159, 81, 9, 203, 207, 164, 33, 20,
+			162, 246, 252, 63, 51, 200, 40, 90, 254, 23, 37, 22, 122, 148,
+			20, 31, 170, 85, 87, 150, 55, 47, 86, 31, 94, 124, 85, 109,
+			173, 190, 121, 117, 117, 253, 74, 117, 169, 246, 80, 173, 186, 92,
+			24, 160, 35, 36, 175, 110, 223, 46, 24, 0, 213, 171, 223, 113,
+			181, 86, 175, 46, 23, 76, 58, 78, 134, 215, 174, 110, 92, 185,
+			186, 177, 185, 182, 186, 242, 88, 193, 162, 99, 132, 212, 86, 53,
+			108, 211, 81, 50, 84, 187, 124, 249, 234, 198, 226, 197, 149, 106,
+			193, 161, 148, 140, 93, 93, 93, 171, 47, 87, 235, 213, 229, 205,
+			149, 218, 250, 70, 33, 71, 111, 33, 19, 171, 107, 171, 155, 213,
+			203, 87, 54, 30, 219, 92, 174, 62, 180, 120, 117, 101, 163, 48,
+			120, 254, 9, 50, 214, 219, 93, 122, 123, 165, 63, 29, 59, 118,
+			68, 134, 105, 76, 191, 47, 207, 172, 217, 177, 115, 135, 42, 41,
+			61, 42, 61, 61, 173, 143, 182, 178, 224, 197, 14, 25, 107, 132,
+			237, 76, 245, 139, 180, 167, 62, 122, 68, 174, 24, 175, 89, 148,
+			53, 182, 67, 223, 13, 182, 43, 97, 180, 125, 102, 155, 7, 216,
+			136, 51, 226, 145, 219, 241, 98, 28, 160, 76, 52, 227, 133, 204,
+			239, 15, 154, 246, 165, 197, 43, 181, 71, 62, 81, 36, 57, 106,
+			143, 13, 60, 102, 144, 159, 179, 137, 49, 66, 173, 177, 1, 122,
+			238, 199, 109, 182, 20, 118, 246, 34, 111, 123, 39, 97, 231, 206,
+			222, 249, 50, 25, 89, 200, 86, 86, 150, 8, 97, 43, 94, 131,
+			7, 49, 94, 220, 215, 148, 86, 222, 98, 7, 180, 10, 245, 164,
+			204, 94, 37, 18, 161, 176, 115, 149, 179, 108, 22, 93, 214, 242,
+			81, 105, 238, 2, 65, 235, 89, 221, 70, 152, 185, 156, 15, 183,
+			71, 26, 188, 131, 211, 74, 228, 55, 116, 131, 6, 79, 83, 46,
+			74, 28, 21, 130, 41, 22, 241, 134, 224, 45, 92, 18, 193, 102,
+			237, 168, 163, 186, 170, 26, 115, 19, 34, 188, 104, 59, 73, 210,
+			57, 127, 230, 204, 238, 238, 110, 197, 197, 134, 34, 201, 124, 81,
+			45, 62, 179, 82, 91, 170, 174, 174, 87, 23, 206, 85, 206, 18,
+			194, 174, 6, 120, 150, 81, 159, 115, 220, 218, 83, 151, 230, 129,
+			182, 235, 187, 187, 232, 123, 220, 142, 100, 166, 39, 47, 80, 169,
+			66, 202, 44, 14, 91, 201, 46, 26, 65, 77, 15, 84, 198, 173,
+			110, 210, 67, 37, 213, 48, 121, 233, 182, 170, 16, 226, 93, 195,
+			165, 197, 117, 86, 91, 47, 177, 139, 139, 235, 181, 245, 50, 97,
+			143, 214, 54, 30, 94, 187, 186, 193, 30, 93, 172, 215, 23, 87,
+			55, 106, 213, 117, 182, 86, 103, 75, 107, 171, 203, 53, 224, 253,
+			117, 182, 246, 16, 91, 92, 125, 140, 189, 178, 182, 186, 92, 86,
+			231, 58, 249, 83, 96, 199, 199, 24, 170, 136, 113, 123, 205, 76,
+			194, 79, 245, 121, 29, 57, 174, 114, 251, 235, 116, 88, 219, 225,
+			117, 30, 161, 105, 133, 137, 26, 98, 121, 109, 97, 208, 36, 12,
+			83, 150, 72, 15, 244, 190, 30, 85, 8, 201, 19, 195, 164, 86,
+			97, 96, 146, 12, 17, 211, 26, 160, 22, 29, 152, 135, 194, 60,
+			181, 166, 6, 94, 13, 133, 249, 97, 241, 83, 20, 222, 50, 80,
+			194, 66, 34, 126, 138, 194, 91, 7, 238, 194, 66, 249, 83, 20,
+			222, 54, 48, 131, 133, 134, 248, 41, 10, 167, 229, 235, 39, 213,
+			79, 99, 144, 218, 197, 129, 89, 131, 252, 142, 69, 204, 193, 1,
+			106, 205, 152, 231, 139, 159, 180, 216, 162, 116, 193, 103, 188, 125,
+			186, 223, 42, 165, 129, 76, 123, 49, 171, 198, 188, 172, 46, 212,
+			5, 221, 172, 140, 23, 177, 204, 97, 208, 149, 154, 231, 61, 249,
+			33, 123, 77, 199, 62, 87, 36, 251, 206, 217, 204, 236, 239, 149,
+			31, 115, 236, 1, 166, 68, 215, 107, 209, 164, 88, 79, 220, 68,
+			94, 235, 113, 51, 47, 103, 36, 157, 120, 191, 95, 28, 45, 119,
+			165, 231, 37, 73, 124, 64, 40, 166, 195, 139, 96, 77, 197, 229,
+			193, 72, 55, 188, 54, 143, 19, 183, 221, 1, 110, 243, 34, 190,
+			153, 120, 162, 175, 55, 133, 61, 211, 230, 178, 244, 113, 191, 72,
+			115, 148, 180, 126, 237, 5, 66, 8, 177, 6, 7, 76, 106, 21,
+			7, 79, 136, 223, 54, 12, 180, 44, 207, 81, 107, 102, 88, 150,
+			27, 212, 154, 57, 121, 78, 252, 182, 168, 53, 115, 207, 253, 228,
+			143, 77, 98, 58, 3, 212, 62, 59, 240, 152, 81, 252, 191, 77,
+			60, 168, 29, 52, 189, 6, 102, 164, 146, 162, 35, 155, 2, 197,
+			149, 215, 145, 11, 46, 153, 109, 101, 119, 174, 132, 43, 39, 163,
+			39, 106, 113, 129, 10, 250, 235, 186, 60, 78, 196, 9, 96, 129,
+			195, 141, 21, 75, 97, 90, 10, 105, 140, 185, 160, 55, 116, 186,
+			201, 156, 58, 205, 62, 63, 175, 246, 209, 230, 231, 179, 249, 152,
+			117, 179, 20, 15, 54, 66, 159, 201, 171, 11, 229, 110, 249, 5,
+			48, 122, 69, 20, 165, 8, 169, 139, 123, 223, 4, 163, 5, 36,
+			166, 204, 162, 176, 19, 238, 178, 197, 43, 53, 12, 225, 0, 126,
+			221, 113, 131, 166, 175, 213, 70, 149, 144, 14, 67, 208, 55, 244,
+			113, 170, 249, 249, 182, 187, 55, 63, 207, 34, 222, 224, 222, 117,
+			142, 9, 58, 123, 47, 159, 215, 251, 79, 132, 88, 14, 12, 194,
+			89, 135, 146, 7, 137, 237, 128, 118, 110, 157, 51, 143, 23, 207,
+			177, 165, 48, 184, 14, 26, 144, 112, 34, 200, 248, 101, 164, 46,
+			158, 213, 203, 156, 32, 198, 133, 1, 195, 83, 29, 7, 117, 101,
+			235, 156, 121, 68, 65, 38, 181, 206, 29, 99, 228, 159, 26, 136,
+			221, 160, 214, 125, 230, 120, 241, 157, 134, 204, 156, 35, 253, 180,
+			138, 20, 202, 197, 239, 198, 218, 86, 174, 16, 246, 40, 102, 88,
+			192, 124, 12, 34, 221, 193, 65, 228, 117, 35, 206, 82, 15, 181,
+			136, 161, 21, 123, 207, 82, 142, 200, 108, 34, 140, 183, 59, 59,
+			110, 236, 225, 29, 204, 153, 43, 18, 117, 251, 13, 108, 163, 134,
+			76, 106, 221, 55, 58, 70, 126, 94, 180, 223, 164, 214, 203, 205,
+			241, 226, 71, 193, 30, 221, 215, 100, 197, 92, 58, 233, 129, 96,
+			91, 158, 201, 225, 33, 234, 206, 207, 183, 187, 49, 240, 206, 22,
+			79, 179, 25, 187, 113, 207, 110, 156, 228, 206, 178, 240, 121, 183,
+			92, 207, 199, 91, 89, 67, 214, 12, 89, 44, 175, 62, 146, 91,
+			194, 1, 227, 81, 4, 130, 177, 43, 239, 7, 125, 162, 182, 250,
+			170, 197, 149, 218, 242, 230, 98, 253, 210, 213, 203, 213, 213, 141,
+			39, 230, 116, 247, 96, 8, 94, 174, 187, 135, 29, 26, 29, 35,
+			95, 19, 221, 179, 168, 117, 209, 164, 197, 63, 57, 176, 123, 25,
+			97, 251, 162, 61, 204, 166, 105, 198, 169, 22, 119, 66, 88, 196,
+			203, 50, 223, 75, 195, 239, 170, 179, 12, 36, 187, 113, 44, 123,
+			221, 115, 177, 156, 208, 76, 228, 181, 216, 130, 114, 106, 131, 214,
+			75, 196, 170, 135, 165, 56, 31, 197, 149, 110, 41, 77, 92, 145,
+			203, 49, 198, 4, 73, 153, 92, 117, 51, 177, 188, 117, 169, 193,
+			83, 218, 88, 6, 244, 127, 84, 65, 38, 181, 46, 22, 38, 200,
+			251, 5, 109, 108, 106, 93, 50, 39, 138, 111, 63, 144, 54, 40,
+			31, 190, 65, 210, 40, 41, 36, 55, 185, 73, 223, 142, 184, 126,
+			11, 102, 91, 214, 51, 170, 189, 162, 162, 185, 182, 1, 13, 28,
+			81, 144, 73, 173, 75, 227, 5, 242, 163, 162, 241, 14, 181, 86,
+			204, 66, 241, 93, 7, 55, 190, 221, 238, 38, 160, 54, 189, 104,
+			219, 213, 140, 226, 208, 215, 6, 239, 29, 179, 36, 148, 225, 223,
+			204, 37, 24, 209, 13, 86, 107, 234, 23, 145, 105, 127, 132, 184,
+			108, 10, 231, 32, 238, 63, 232, 30, 56, 6, 180, 114, 88, 65,
+			38, 181, 86, 198, 198, 201, 59, 77, 236, 65, 142, 90, 117, 243,
+			150, 226, 155, 76, 221, 3, 41, 220, 103, 149, 135, 124, 46, 147,
+			190, 51, 96, 221, 0, 211, 132, 240, 38, 243, 61, 244, 48, 223,
+			168, 103, 234, 244, 31, 180, 81, 93, 254, 155, 241, 70, 232, 84,
+			62, 128, 6, 181, 11, 55, 216, 99, 110, 180, 229, 37, 145, 27,
+			237, 49, 145, 141, 164, 204, 34, 183, 215, 177, 45, 147, 148, 136,
+			235, 95, 34, 109, 204, 131, 224, 215, 183, 134, 177, 69, 157, 133,
+			199, 151, 151, 15, 192, 87, 102, 98, 249, 54, 170, 206, 81, 150,
+			122, 177, 28, 39, 73, 163, 156, 1, 84, 41, 40, 200, 164, 86,
+			125, 114, 138, 252, 67, 65, 177, 65, 106, 189, 218, 156, 46, 62,
+			111, 244, 82, 44, 147, 93, 67, 132, 195, 199, 242, 50, 67, 177,
+			51, 161, 36, 189, 204, 134, 214, 82, 89, 78, 111, 64, 65, 153,
+			16, 6, 251, 40, 251, 37, 13, 112, 196, 166, 46, 1, 73, 217,
+			4, 4, 90, 230, 115, 162, 66, 234, 96, 21, 241, 249, 194, 212,
+			70, 164, 217, 148, 81, 238, 46, 74, 249, 22, 134, 45, 147, 125,
+			88, 196, 45, 202, 194, 205, 32, 40, 50, 104, 0, 13, 38, 21,
+			100, 82, 235, 213, 183, 222, 182, 149, 19, 105, 252, 200, 175, 220,
+			169, 141, 100, 109, 247, 38, 74, 107, 146, 70, 242, 120, 159, 90,
+			85, 186, 64, 134, 180, 102, 69, 167, 201, 96, 204, 27, 97, 208,
+			140, 167, 13, 102, 204, 90, 117, 5, 210, 41, 226, 4, 110, 16,
+			198, 211, 38, 51, 102, 157, 186, 0, 46, 190, 201, 32, 147, 25,
+			211, 81, 33, 189, 56, 166, 81, 42, 219, 241, 220, 126, 219, 49,
+			109, 35, 94, 158, 130, 55, 148, 167, 237, 237, 108, 125, 217, 48,
+			62, 104, 90, 151, 174, 92, 252, 176, 121, 84, 24, 130, 149, 43,
+			74, 25, 124, 148, 251, 254, 43, 225, 133, 13, 120, 247, 145, 95,
+			62, 67, 6, 169, 115, 116, 224, 251, 13, 131, 252, 251, 17, 52,
+			40, 143, 14, 208, 115, 191, 48, 162, 15, 127, 169, 147, 95, 108,
+			33, 61, 174, 134, 23, 107, 96, 64, 141, 212, 121, 132, 215, 141,
+			244, 88, 161, 103, 181, 21, 90, 11, 26, 21, 134, 153, 224, 241,
+			89, 172, 15, 193, 85, 200, 215, 123, 237, 51, 33, 172, 206, 181,
+			45, 134, 225, 87, 242, 10, 27, 79, 229, 2, 197, 146, 45, 47,
+			128, 57, 9, 237, 82, 91, 139, 120, 253, 39, 134, 215, 17, 214,
+			14, 155, 168, 114, 136, 157, 82, 220, 139, 214, 89, 13, 181, 76,
+			78, 69, 158, 14, 35, 133, 33, 245, 116, 110, 48, 60, 9, 118,
+			94, 218, 172, 243, 125, 13, 139, 251, 227, 132, 113, 101, 138, 184,
+			190, 1, 86, 132, 57, 52, 20, 197, 128, 139, 19, 60, 23, 140,
+			243, 18, 166, 191, 216, 140, 76, 191, 40, 47, 117, 76, 155, 211,
+			244, 226, 134, 239, 122, 109, 46, 182, 171, 14, 106, 132, 23, 100,
+			105, 161, 26, 33, 207, 181, 164, 237, 32, 105, 67, 190, 169, 118,
+			168, 163, 46, 125, 17, 201, 110, 208, 60, 3, 74, 58, 74, 198,
+			182, 155, 96, 38, 225, 204, 242, 167, 60, 6, 25, 67, 92, 133,
+			210, 66, 167, 86, 165, 181, 156, 100, 206, 198, 100, 121, 43, 8,
+			211, 103, 177, 116, 109, 99, 178, 123, 129, 42, 140, 180, 10, 136,
+			129, 166, 152, 122, 180, 25, 70, 49, 151, 103, 124, 218, 97, 194,
+			255, 63, 234, 190, 45, 54, 142, 100, 59, 140, 221, 61, 164, 200,
+			218, 149, 68, 181, 168, 199, 206, 106, 119, 107, 103, 197, 213, 204,
+			238, 112, 134, 164, 40, 174, 72, 105, 55, 30, 206, 180, 164, 94,
+			143, 102, 184, 243, 144, 86, 90, 236, 82, 205, 233, 26, 178, 175,
+			122, 186, 231, 118, 247, 144, 226, 165, 245, 225, 4, 137, 13, 95,
+			192, 72, 0, 39, 136, 129, 56, 118, 254, 236, 228, 126, 228, 43,
+			241, 71, 0, 255, 4, 184, 65, 98, 36, 63, 193, 5, 226, 32,
+			65, 12, 127, 56, 129, 13, 3, 55, 31, 9, 2, 220, 0, 65,
+			157, 58, 213, 221, 67, 142, 86, 119, 47, 252, 19, 65, 18, 166,
+			186, 187, 170, 206, 57, 117, 170, 234, 212, 169, 243, 64, 95, 159,
+			40, 28, 75, 12, 137, 150, 34, 82, 81, 16, 27, 104, 198, 71,
+			210, 97, 192, 133, 245, 195, 128, 243, 142, 151, 58, 133, 67, 84,
+			180, 7, 102, 155, 182, 155, 247, 58, 143, 43, 45, 131, 154, 109,
+			186, 221, 106, 62, 50, 107, 70, 141, 110, 61, 161, 157, 7, 6,
+			173, 54, 183, 159, 180, 204, 251, 15, 58, 244, 65, 179, 94, 51,
+			90, 109, 90, 105, 212, 104, 181, 217, 232, 180, 204, 173, 110, 167,
+			217, 106, 147, 88, 205, 192, 223, 84, 26, 79, 168, 241, 229, 118,
+			203, 104, 131, 110, 193, 124, 184, 93, 55, 141, 90, 74, 227, 80,
+			164, 102, 163, 90, 239, 214, 204, 198, 253, 34, 221, 234, 118, 104,
+			163, 217, 33, 180, 110, 62, 52, 59, 70, 141, 118, 154, 69, 232,
+			246, 116, 61, 218, 188, 71, 31, 26, 173, 234, 131, 74, 163, 83,
+			217, 50, 235, 102, 231, 9, 116, 120, 207, 236, 52, 120, 103, 247,
+			154, 45, 66, 43, 116, 187, 210, 234, 152, 213, 110, 189, 210, 162,
+			219, 221, 214, 118, 179, 109, 80, 142, 89, 205, 108, 87, 235, 21,
+			243, 161, 81, 43, 81, 179, 65, 27, 77, 106, 60, 50, 26, 29,
+			218, 126, 80, 169, 215, 199, 17, 37, 180, 249, 184, 97, 180, 80,
+			51, 18, 163, 73, 183, 12, 90, 55, 249, 161, 145, 119, 5, 120,
+			214, 204, 150, 81, 237, 112, 132, 146, 95, 85, 179, 102, 52, 58,
+			149, 122, 145, 80, 208, 47, 86, 234, 69, 106, 124, 105, 60, 220,
+			174, 87, 90, 79, 138, 216, 104, 219, 248, 162, 107, 52, 58, 102,
+			165, 78, 107, 149, 135, 149, 251, 70, 155, 230, 95, 71, 149, 237,
+			86, 179, 218, 109, 25, 92, 66, 230, 164, 104, 119, 183, 218, 29,
+			179, 211, 237, 24, 244, 126, 179, 89, 3, 98, 183, 141, 214, 35,
+			179, 106, 180, 239, 208, 122, 179, 13, 4, 235, 182, 141, 34, 161,
+			181, 74, 167, 2, 93, 111, 183, 154, 247, 204, 78, 251, 14, 255,
+			189, 213, 109, 155, 64, 56, 179, 209, 49, 90, 173, 46, 104, 63,
+			11, 244, 65, 243, 177, 241, 200, 104, 209, 106, 165, 219, 54, 106,
+			64, 225, 102, 131, 99, 203, 121, 197, 104, 182, 158, 240, 102, 57,
+			29, 96, 4, 138, 244, 241, 3, 163, 243, 192, 104, 113, 162, 2,
+			181, 42, 156, 12, 237, 78, 203, 172, 118, 210, 159, 53, 91, 180,
+			211, 108, 117, 72, 10, 79, 218, 48, 238, 215, 205, 251, 70, 163,
+			106, 240, 215, 77, 222, 204, 99, 179, 109, 20, 104, 165, 101, 182,
+			249, 7, 38, 116, 76, 31, 87, 158, 240, 35, 62, 239, 152, 15,
+			84, 183, 109, 16, 241, 59, 197, 186, 69, 24, 79, 106, 222, 163,
+			149, 218, 35, 147, 67, 142, 95, 111, 55, 219, 109, 19, 217, 5,
+			200, 86, 125, 128, 52, 143, 21, 76, 116, 234, 42, 106, 128, 114,
+			83, 119, 64, 3, 180, 40, 126, 138, 135, 31, 76, 189, 7, 15,
+			223, 19, 63, 197, 195, 235, 83, 166, 212, 63, 241, 159, 226, 225,
+			226, 84, 81, 106, 149, 248, 79, 241, 240, 195, 169, 178, 212, 63,
+			241, 159, 226, 225, 141, 68, 83, 117, 35, 214, 84, 229, 167, 222,
+			151, 250, 39, 254, 243, 191, 190, 67, 212, 204, 148, 62, 243, 107,
+			10, 223, 250, 178, 255, 225, 29, 90, 161, 137, 158, 100, 204, 186,
+			70, 4, 123, 229, 203, 154, 51, 16, 62, 23, 24, 57, 85, 6,
+			49, 132, 231, 63, 0, 215, 39, 97, 106, 235, 66, 208, 64, 230,
+			217, 86, 80, 76, 5, 22, 6, 173, 231, 72, 212, 67, 233, 64,
+			28, 233, 2, 171, 151, 236, 24, 242, 5, 223, 16, 184, 168, 0,
+			101, 225, 23, 41, 22, 69, 17, 190, 27, 26, 2, 61, 134, 43,
+			130, 230, 227, 253, 222, 208, 239, 237, 83, 43, 162, 221, 78, 149,
+			14, 28, 219, 131, 21, 29, 130, 193, 90, 222, 136, 111, 3, 43,
+			69, 186, 178, 241, 201, 114, 81, 46, 212, 195, 192, 119, 217, 48,
+			114, 122, 244, 126, 192, 246, 252, 192, 177, 188, 24, 122, 52, 0,
+			18, 214, 203, 161, 88, 160, 39, 124, 21, 167, 222, 129, 164, 163,
+			204, 10, 168, 239, 49, 153, 252, 101, 224, 120, 163, 8, 205, 57,
+			214, 151, 99, 252, 92, 223, 219, 43, 209, 58, 179, 134, 9, 202,
+			1, 163, 185, 112, 192, 172, 128, 217, 57, 10, 247, 36, 22, 223,
+			141, 168, 203, 172, 33, 193, 207, 40, 200, 189, 34, 140, 32, 147,
+			185, 127, 98, 219, 94, 220, 208, 133, 151, 187, 69, 191, 90, 93,
+			91, 218, 247, 71, 194, 56, 194, 10, 8, 133, 214, 191, 206, 127,
+			187, 208, 193, 199, 179, 12, 95, 22, 100, 166, 158, 0, 164, 28,
+			7, 179, 18, 46, 47, 47, 175, 44, 193, 223, 206, 242, 242, 38,
+			252, 125, 202, 81, 223, 216, 216, 216, 88, 90, 89, 93, 186, 185,
+			210, 89, 189, 185, 121, 107, 99, 243, 214, 70, 105, 67, 254, 121,
+			90, 162, 91, 71, 36, 201, 57, 35, 77, 156, 173, 72, 180, 94,
+			164, 135, 140, 50, 47, 132, 195, 61, 4, 91, 133, 12, 60, 50,
+			6, 177, 24, 95, 180, 170, 249, 170, 117, 175, 74, 232, 205, 155,
+			55, 55, 18, 92, 14, 15, 15, 75, 14, 139, 250, 32, 33, 6,
+			253, 30, 255, 199, 191, 40, 69, 47, 162, 2, 151, 216, 88, 42,
+			80, 45, 253, 32, 214, 130, 38, 10, 81, 186, 178, 73, 171, 254,
+			96, 56, 138, 88, 106, 46, 64, 135, 219, 205, 182, 249, 37, 125,
+			198, 41, 147, 47, 60, 43, 161, 200, 147, 124, 20, 11, 159, 152,
+			73, 34, 17, 158, 67, 22, 237, 224, 0, 231, 161, 122, 163, 91,
+			175, 23, 10, 19, 191, 3, 126, 207, 47, 23, 238, 164, 96, 90,
+			125, 29, 76, 123, 44, 226, 173, 248, 125, 219, 58, 74, 193, 38,
+			2, 218, 67, 7, 7, 150, 75, 163, 3, 236, 113, 236, 243, 15,
+			163, 131, 34, 5, 128, 238, 252, 162, 40, 29, 148, 162, 3, 94,
+			250, 54, 140, 196, 71, 163, 144, 245, 232, 71, 116, 101, 121, 121,
+			28, 195, 155, 175, 196, 240, 177, 227, 221, 92, 165, 207, 238, 179,
+			168, 13, 151, 194, 252, 117, 37, 188, 231, 184, 172, 51, 62, 16,
+			247, 204, 186, 209, 49, 31, 26, 180, 31, 33, 24, 175, 170, 243,
+			97, 63, 146, 144, 118, 205, 70, 103, 125, 141, 70, 78, 239, 121,
+			72, 63, 165, 249, 124, 94, 60, 41, 244, 163, 146, 125, 248, 192,
+			217, 219, 175, 89, 17, 212, 42, 208, 187, 119, 233, 205, 213, 2,
+			253, 21, 10, 239, 234, 254, 161, 124, 37, 233, 86, 46, 211, 10,
+			135, 215, 246, 15, 67, 104, 146, 79, 150, 149, 229, 229, 212, 26,
+			22, 150, 226, 15, 196, 42, 181, 178, 126, 122, 26, 197, 173, 241,
+			234, 43, 235, 107, 107, 107, 159, 220, 92, 95, 78, 150, 13, 180,
+			194, 235, 122, 206, 11, 217, 202, 198, 39, 203, 39, 91, 41, 253,
+			98, 131, 153, 23, 248, 211, 124, 94, 16, 165, 12, 131, 197, 255,
+			20, 232, 82, 26, 156, 215, 112, 48, 111, 135, 147, 75, 182, 179,
+			152, 106, 7, 24, 160, 48, 198, 0, 107, 175, 100, 128, 207, 173,
+			3, 139, 62, 19, 3, 89, 66, 243, 66, 254, 201, 67, 199, 117,
+			157, 48, 197, 0, 16, 203, 99, 0, 79, 233, 167, 244, 213, 21,
+			190, 133, 205, 233, 167, 201, 211, 146, 199, 14, 183, 70, 142, 107,
+			179, 32, 95, 224, 136, 181, 145, 66, 216, 133, 32, 76, 33, 73,
+			46, 195, 191, 105, 8, 220, 29, 47, 226, 152, 227, 151, 2, 117,
+			68, 27, 40, 80, 40, 129, 63, 63, 192, 146, 208, 224, 214, 107,
+			104, 96, 122, 97, 100, 121, 81, 201, 243, 15, 83, 104, 227, 83,
+			72, 158, 253, 41, 29, 251, 230, 91, 49, 77, 0, 127, 61, 202,
+			158, 127, 88, 218, 99, 145, 193, 153, 77, 60, 203, 23, 82, 152,
+			143, 99, 143, 31, 243, 66, 254, 21, 152, 174, 191, 18, 83, 105,
+			63, 138, 114, 6, 221, 62, 138, 246, 197, 65, 98, 140, 209, 210,
+			3, 149, 47, 156, 228, 194, 251, 44, 170, 38, 227, 158, 47, 192,
+			90, 255, 121, 187, 217, 160, 15, 69, 152, 66, 66, 168, 233, 137,
+			39, 226, 212, 46, 212, 77, 41, 58, 29, 13, 217, 120, 70, 4,
+			106, 201, 91, 50, 33, 51, 16, 216, 128, 190, 211, 254, 35, 115,
+			237, 197, 1, 113, 133, 190, 87, 164, 169, 115, 66, 154, 59, 230,
+			114, 195, 203, 165, 227, 129, 239, 69, 251, 47, 151, 142, 109, 235,
+			232, 101, 231, 152, 111, 222, 47, 55, 143, 7, 142, 247, 114, 243,
+			56, 100, 189, 151, 95, 149, 142, 185, 184, 196, 167, 236, 203, 175,
+			159, 230, 8, 58, 130, 136, 218, 160, 240, 19, 182, 238, 120, 175,
+			202, 100, 206, 5, 48, 115, 183, 157, 61, 39, 10, 49, 78, 51,
+			246, 84, 164, 208, 85, 145, 80, 209, 89, 145, 66, 111, 66, 9,
+			11, 93, 38, 118, 229, 67, 136, 119, 15, 219, 246, 161, 47, 91,
+			99, 86, 111, 95, 200, 100, 82, 142, 227, 242, 31, 46, 41, 169,
+			228, 107, 116, 207, 167, 163, 33, 136, 9, 178, 170, 240, 151, 20,
+			15, 87, 38, 75, 123, 133, 34, 25, 187, 208, 16, 61, 229, 158,
+			230, 104, 56, 234, 247, 157, 23, 99, 90, 56, 6, 124, 0, 146,
+			104, 62, 215, 237, 84, 115, 133, 59, 99, 79, 199, 46, 190, 74,
+			180, 130, 89, 44, 4, 51, 200, 236, 62, 44, 136, 211, 183, 37,
+			25, 139, 184, 52, 153, 183, 18, 157, 159, 200, 142, 151, 123, 154,
+			43, 8, 39, 195, 97, 224, 120, 113, 172, 229, 19, 172, 36, 124,
+			194, 210, 93, 201, 144, 44, 210, 106, 134, 80, 144, 233, 184, 132,
+			211, 3, 115, 2, 112, 41, 227, 125, 242, 186, 104, 39, 140, 56,
+			132, 167, 224, 0, 247, 75, 176, 195, 41, 156, 114, 109, 206, 173,
+			46, 175, 124, 194, 119, 135, 149, 91, 157, 229, 149, 205, 155, 203,
+			155, 43, 183, 74, 203, 43, 79, 115, 200, 221, 33, 133, 114, 188,
+			189, 8, 211, 29, 248, 18, 250, 247, 189, 68, 110, 190, 85, 164,
+			188, 181, 18, 78, 160, 56, 100, 127, 81, 24, 109, 167, 68, 53,
+			139, 242, 237, 17, 45, 157, 132, 148, 23, 91, 166, 37, 17, 152,
+			72, 156, 186, 143, 208, 175, 34, 223, 108, 55, 219, 48, 201, 242,
+			133, 9, 2, 106, 105, 224, 255, 192, 113, 93, 11, 102, 23, 243,
+			150, 186, 237, 178, 237, 247, 194, 242, 99, 182, 91, 78, 64, 41,
+			183, 164, 169, 97, 249, 190, 235, 239, 90, 238, 78, 83, 68, 61,
+			41, 115, 128, 202, 169, 78, 10, 50, 138, 17, 100, 51, 17, 43,
+			77, 17, 230, 57, 102, 19, 124, 198, 37, 70, 78, 244, 146, 252,
+			241, 76, 34, 132, 105, 94, 226, 228, 24, 100, 34, 138, 132, 126,
+			245, 44, 140, 130, 62, 84, 77, 97, 228, 247, 194, 210, 80, 172,
+			108, 28, 151, 213, 178, 235, 236, 6, 86, 112, 4, 98, 119, 105,
+			63, 26, 184, 31, 192, 47, 89, 183, 0, 58, 23, 18, 51, 178,
+			236, 36, 28, 178, 30, 189, 177, 248, 100, 105, 113, 176, 180, 104,
+			119, 22, 31, 108, 46, 62, 220, 92, 108, 151, 22, 251, 79, 111,
+			148, 104, 221, 121, 206, 14, 157, 144, 193, 49, 135, 19, 40, 25,
+			37, 8, 219, 15, 217, 53, 124, 219, 2, 102, 189, 17, 210, 175,
+			158, 153, 237, 166, 20, 106, 238, 137, 197, 202, 198, 98, 190, 240,
+			236, 235, 60, 73, 27, 165, 124, 207, 183, 197, 72, 240, 31, 75,
+			112, 94, 176, 134, 14, 12, 136, 124, 42, 78, 17, 2, 214, 242,
+			233, 182, 1, 79, 217, 193, 226, 106, 109, 113, 181, 70, 104, 1,
+			98, 120, 73, 11, 25, 105, 201, 24, 208, 158, 53, 132, 9, 226,
+			247, 211, 246, 154, 241, 154, 159, 74, 25, 47, 26, 39, 68, 152,
+			145, 66, 208, 82, 101, 246, 2, 249, 29, 133, 100, 68, 84, 169,
+			31, 42, 234, 66, 246, 55, 21, 218, 74, 78, 184, 146, 247, 253,
+			62, 176, 60, 208, 56, 116, 188, 94, 90, 202, 34, 147, 197, 44,
+			250, 16, 163, 159, 127, 219, 177, 136, 76, 58, 23, 61, 21, 23,
+			88, 161, 19, 135, 89, 18, 198, 150, 63, 84, 212, 51, 178, 168,
+			240, 226, 236, 121, 89, 212, 120, 81, 191, 40, 34, 224, 130, 17,
+			229, 223, 83, 84, 61, 251, 19, 133, 54, 124, 111, 201, 99, 123,
+			226, 28, 60, 118, 154, 182, 228, 169, 145, 31, 36, 39, 159, 166,
+			27, 88, 49, 62, 96, 226, 5, 57, 168, 36, 147, 198, 64, 113,
+			42, 114, 198, 129, 11, 178, 151, 238, 19, 154, 198, 138, 104, 0,
+			41, 14, 232, 125, 63, 224, 7, 99, 169, 61, 56, 73, 48, 60,
+			52, 22, 241, 31, 153, 64, 20, 101, 26, 240, 148, 68, 81, 0,
+			237, 217, 179, 178, 168, 241, 226, 252, 133, 248, 38, 227, 223, 62,
+			33, 75, 142, 215, 15, 172, 50, 248, 128, 239, 57, 30, 43, 31,
+			50, 22, 237, 58, 47, 132, 50, 189, 124, 176, 82, 238, 249, 131,
+			129, 239, 73, 227, 63, 124, 93, 58, 88, 201, 190, 206, 82, 48,
+			251, 186, 91, 146, 220, 161, 184, 20, 105, 241, 19, 173, 190, 78,
+			102, 153, 21, 184, 14, 11, 35, 184, 21, 121, 99, 53, 123, 202,
+			0, 47, 222, 44, 90, 241, 183, 250, 42, 153, 1, 3, 204, 8,
+			238, 76, 190, 189, 22, 126, 153, 91, 39, 111, 118, 88, 24, 181,
+			224, 130, 215, 180, 245, 203, 100, 70, 88, 210, 66, 207, 115, 45,
+			44, 233, 231, 136, 234, 216, 208, 238, 92, 75, 117, 236, 220, 247,
+			201, 153, 71, 86, 224, 88, 94, 164, 151, 136, 102, 179, 254, 85,
+			133, 106, 249, 55, 86, 175, 149, 18, 186, 148, 240, 139, 82, 141,
+			245, 33, 23, 98, 139, 127, 152, 93, 39, 179, 242, 129, 62, 79,
+			180, 231, 236, 8, 251, 226, 63, 245, 5, 50, 13, 12, 129, 125,
+			137, 194, 166, 122, 91, 201, 173, 17, 34, 22, 225, 109, 203, 9,
+			126, 222, 154, 185, 58, 89, 216, 26, 237, 117, 2, 171, 247, 220,
+			241, 246, 170, 210, 251, 249, 149, 136, 94, 35, 115, 177, 139, 52,
+			182, 148, 60, 200, 221, 38, 231, 182, 3, 22, 142, 118, 7, 78,
+			212, 26, 121, 223, 129, 96, 67, 114, 182, 18, 91, 196, 111, 141,
+			246, 126, 222, 138, 58, 37, 115, 174, 227, 61, 223, 137, 216, 139,
+			232, 170, 198, 31, 111, 105, 255, 173, 162, 181, 102, 249, 211, 14,
+			123, 17, 233, 151, 136, 54, 10, 220, 171, 153, 228, 29, 47, 231,
+			54, 200, 92, 213, 29, 133, 17, 11, 76, 155, 163, 101, 185, 123,
+			126, 224, 68, 251, 178, 195, 228, 193, 41, 96, 191, 20, 92, 241,
+			16, 147, 193, 235, 58, 201, 120, 214, 128, 97, 69, 248, 173, 175,
+			145, 89, 233, 92, 138, 252, 118, 53, 61, 246, 188, 190, 180, 23,
+			111, 197, 95, 230, 218, 162, 101, 249, 134, 183, 28, 176, 161, 47,
+			91, 230, 191, 245, 183, 201, 92, 223, 113, 217, 14, 116, 41, 128,
+			154, 229, 15, 26, 188, 91, 157, 100, 92, 199, 99, 64, 137, 233,
+			22, 252, 254, 232, 223, 41, 228, 130, 241, 194, 151, 41, 126, 219,
+			145, 21, 141, 66, 61, 71, 222, 53, 190, 108, 54, 140, 86, 165,
+			99, 54, 27, 59, 237, 78, 165, 211, 109, 159, 48, 177, 213, 201,
+			185, 70, 179, 179, 35, 191, 51, 106, 194, 208, 22, 52, 253, 85,
+			179, 51, 175, 242, 146, 241, 37, 150, 52, 253, 13, 114, 230, 177,
+			97, 116, 182, 204, 47, 231, 51, 250, 21, 114, 177, 89, 173, 118,
+			91, 237, 157, 102, 99, 7, 84, 197, 59, 213, 122, 123, 158, 232,
+			151, 137, 158, 188, 120, 88, 49, 27, 117, 179, 97, 204, 47, 232,
+			243, 228, 77, 222, 91, 181, 101, 118, 204, 106, 165, 62, 255, 46,
+			239, 95, 84, 140, 251, 176, 63, 250, 154, 156, 111, 3, 131, 69,
+			204, 190, 231, 184, 17, 11, 116, 74, 174, 181, 187, 91, 15, 205,
+			78, 199, 168, 237, 220, 51, 235, 29, 227, 164, 173, 48, 111, 168,
+			81, 127, 178, 19, 127, 54, 175, 232, 11, 100, 30, 158, 117, 27,
+			201, 83, 117, 171, 248, 244, 163, 215, 173, 116, 119, 240, 193, 112,
+			247, 243, 63, 104, 146, 51, 250, 116, 102, 234, 215, 85, 133, 252,
+			83, 5, 46, 58, 51, 83, 250, 234, 239, 42, 99, 119, 150, 171,
+			43, 32, 95, 87, 247, 3, 127, 224, 140, 6, 180, 2, 9, 118,
+			32, 123, 231, 164, 203, 203, 110, 152, 164, 244, 25, 11, 9, 20,
+			162, 101, 38, 10, 168, 116, 171, 93, 91, 10, 163, 35, 136, 172,
+			35, 108, 58, 197, 38, 1, 146, 20, 63, 7, 141, 188, 216, 199,
+			13, 173, 91, 165, 35, 134, 80, 163, 207, 36, 118, 154, 179, 83,
+			5, 248, 169, 232, 218, 220, 84, 1, 213, 219, 111, 76, 85, 164,
+			202, 156, 255, 188, 14, 218, 237, 204, 185, 169, 5, 37, 123, 149,
+			86, 80, 127, 201, 65, 141, 133, 6, 97, 243, 197, 101, 4, 237,
+			220, 236, 5, 114, 23, 37, 4, 109, 94, 45, 100, 203, 34, 229,
+			155, 107, 131, 53, 71, 34, 206, 167, 242, 40, 162, 79, 34, 111,
+			87, 92, 178, 243, 218, 51, 188, 250, 219, 178, 164, 232, 218, 252,
+			181, 235, 178, 164, 233, 218, 252, 141, 60, 49, 113, 243, 214, 46,
+			170, 55, 178, 119, 169, 137, 237, 137, 52, 84, 137, 68, 35, 76,
+			58, 2, 161, 179, 20, 161, 197, 92, 59, 177, 170, 144, 86, 102,
+			188, 169, 25, 222, 150, 236, 84, 225, 45, 95, 203, 201, 146, 166,
+			107, 23, 23, 63, 36, 121, 2, 30, 70, 87, 166, 114, 74, 246,
+			26, 58, 8, 136, 76, 65, 22, 229, 187, 7, 218, 5, 33, 73,
+			120, 27, 87, 102, 23, 200, 99, 146, 129, 12, 98, 90, 86, 93,
+			200, 126, 46, 194, 240, 36, 31, 135, 84, 172, 115, 37, 66, 171,
+			73, 14, 32, 48, 248, 224, 184, 28, 88, 174, 99, 39, 153, 228,
+			114, 162, 146, 189, 155, 67, 192, 21, 46, 238, 104, 89, 117, 86,
+			150, 20, 93, 203, 206, 157, 151, 37, 77, 215, 178, 250, 69, 242,
+			15, 85, 128, 65, 209, 181, 247, 213, 249, 236, 111, 168, 212, 172,
+			197, 214, 192, 41, 88, 226, 108, 185, 19, 193, 227, 39, 162, 177,
+			55, 142, 71, 197, 62, 89, 219, 42, 226, 77, 46, 158, 195, 55,
+			9, 205, 57, 222, 129, 12, 93, 81, 62, 54, 27, 143, 154, 85,
+			177, 238, 152, 181, 151, 101, 222, 76, 88, 62, 238, 182, 234, 59,
+			70, 187, 90, 217, 54, 106, 59, 29, 163, 221, 129, 119, 216, 122,
+			249, 184, 101, 180, 187, 117, 120, 150, 35, 244, 49, 156, 207, 199,
+			154, 41, 210, 9, 245, 225, 168, 23, 215, 132, 161, 71, 65, 12,
+			163, 78, 144, 52, 216, 49, 17, 149, 105, 78, 26, 73, 68, 62,
+			114, 239, 207, 189, 33, 75, 154, 174, 189, 127, 238, 60, 249, 99,
+			133, 168, 25, 85, 207, 228, 167, 138, 74, 246, 143, 20, 138, 187,
+			249, 248, 45, 207, 161, 5, 252, 16, 140, 132, 167, 42, 242, 69,
+			207, 2, 91, 105, 24, 123, 240, 37, 138, 159, 198, 201, 46, 95,
+			176, 222, 8, 147, 146, 38, 174, 93, 252, 224, 44, 146, 254, 200,
+			24, 57, 190, 71, 82, 239, 155, 237, 34, 189, 191, 221, 149, 166,
+			9, 201, 11, 140, 147, 25, 71, 82, 244, 3, 14, 146, 56, 247,
+			184, 214, 158, 156, 181, 156, 35, 242, 179, 231, 201, 223, 229, 178,
+			176, 202, 121, 244, 99, 245, 221, 236, 223, 20, 1, 255, 146, 136,
+			24, 73, 242, 49, 33, 191, 8, 143, 228, 231, 236, 104, 73, 48,
+			230, 208, 114, 130, 49, 50, 64, 94, 36, 107, 192, 248, 169, 67,
+			56, 126, 236, 130, 177, 169, 127, 152, 240, 215, 161, 21, 114, 152,
+			208, 141, 24, 49, 65, 163, 107, 28, 23, 21, 150, 130, 143, 213,
+			75, 178, 164, 232, 218, 199, 151, 223, 146, 37, 77, 215, 62, 190,
+			246, 14, 33, 4, 220, 252, 74, 83, 183, 20, 64, 138, 175, 104,
+			165, 89, 157, 252, 50, 201, 100, 52, 142, 211, 138, 122, 33, 251,
+			25, 109, 177, 61, 246, 98, 147, 126, 243, 149, 181, 244, 131, 175,
+			249, 127, 203, 75, 27, 59, 95, 127, 148, 47, 159, 120, 80, 248,
+			232, 58, 161, 15, 173, 23, 232, 90, 179, 73, 215, 215, 16, 28,
+			13, 230, 218, 10, 178, 137, 6, 224, 172, 204, 189, 41, 75, 154,
+			174, 173, 156, 159, 39, 239, 65, 183, 138, 174, 173, 169, 23, 179,
+			250, 88, 75, 171, 183, 214, 227, 166, 56, 199, 173, 197, 77, 113,
+			142, 91, 155, 59, 39, 75, 154, 174, 173, 93, 208, 73, 157, 128,
+			207, 227, 237, 41, 67, 201, 254, 210, 137, 245, 102, 119, 180, 71,
+			35, 148, 226, 146, 32, 54, 194, 50, 106, 236, 157, 156, 191, 64,
+			155, 140, 162, 107, 183, 103, 175, 129, 21, 95, 38, 195, 137, 115,
+			87, 93, 200, 254, 125, 49, 224, 19, 170, 77, 10, 166, 227, 132,
+			9, 251, 66, 192, 154, 196, 177, 137, 200, 56, 173, 191, 248, 2,
+			55, 240, 61, 63, 176, 28, 87, 46, 112, 25, 32, 250, 93, 164,
+			84, 6, 136, 126, 23, 23, 184, 12, 16, 253, 174, 126, 145, 252,
+			76, 5, 124, 20, 93, 171, 169, 87, 178, 127, 165, 158, 198, 39,
+			33, 209, 95, 43, 74, 38, 218, 143, 79, 32, 157, 19, 82, 137,
+			76, 49, 14, 5, 46, 131, 216, 10, 80, 44, 188, 12, 21, 134,
+			105, 160, 198, 10, 25, 163, 78, 36, 146, 97, 210, 156, 201, 37,
+			147, 207, 184, 132, 248, 217, 61, 215, 122, 238, 120, 44, 12, 115,
+			37, 145, 79, 62, 213, 54, 0, 64, 18, 8, 134, 129, 15, 42,
+			22, 49, 183, 114, 61, 148, 67, 114, 5, 105, 246, 137, 74, 89,
+			97, 76, 41, 99, 110, 96, 100, 134, 216, 188, 57, 117, 181, 203,
+			91, 187, 17, 210, 199, 66, 14, 162, 61, 223, 235, 59, 123, 163,
+			56, 92, 131, 24, 12, 206, 210, 181, 120, 160, 56, 75, 215, 230,
+			116, 89, 210, 116, 173, 118, 233, 50, 249, 130, 128, 215, 238, 131,
+			169, 182, 146, 53, 78, 176, 244, 80, 158, 36, 196, 186, 96, 185,
+			161, 79, 193, 40, 77, 232, 142, 115, 213, 47, 104, 107, 228, 229,
+			248, 98, 150, 171, 62, 130, 223, 5, 228, 235, 105, 69, 215, 30,
+			204, 94, 38, 191, 205, 249, 122, 154, 243, 117, 93, 93, 200, 254,
+			80, 240, 53, 142, 135, 184, 13, 181, 194, 216, 120, 103, 24, 248,
+			61, 22, 134, 136, 99, 170, 239, 159, 147, 85, 221, 81, 207, 89,
+			234, 29, 228, 96, 129, 174, 119, 171, 38, 120, 218, 59, 17, 125,
+			196, 2, 78, 192, 128, 208, 188, 120, 252, 72, 174, 104, 211, 192,
+			205, 117, 36, 210, 52, 112, 115, 29, 185, 121, 26, 184, 185, 174,
+			95, 36, 255, 90, 96, 33, 108, 49, 179, 255, 66, 25, 163, 211,
+			36, 104, 205, 147, 143, 19, 22, 68, 0, 198, 54, 104, 41, 107,
+			74, 84, 32, 180, 77, 238, 152, 127, 186, 179, 221, 106, 126, 110,
+			84, 59, 47, 203, 162, 88, 125, 4, 27, 176, 76, 206, 74, 249,
+			190, 206, 133, 229, 219, 27, 183, 111, 223, 94, 217, 88, 91, 191,
+			121, 251, 214, 218, 210, 202, 82, 127, 99, 237, 147, 155, 171, 125,
+			182, 186, 188, 124, 107, 189, 111, 175, 228, 98, 132, 57, 87, 180,
+			98, 132, 57, 87, 180, 112, 107, 157, 6, 174, 104, 157, 59, 79,
+			54, 8, 36, 74, 127, 52, 229, 40, 217, 165, 9, 11, 157, 92,
+			213, 150, 38, 175, 106, 51, 138, 174, 61, 154, 189, 68, 246, 72,
+			38, 51, 195, 7, 255, 137, 186, 144, 125, 138, 247, 87, 114, 214,
+			77, 154, 162, 126, 60, 123, 147, 24, 5, 194, 70, 140, 164, 86,
+			34, 96, 184, 221, 209, 158, 5, 97, 108, 37, 102, 51, 48, 148,
+			79, 16, 179, 25, 24, 202, 39, 56, 148, 51, 48, 148, 79, 244,
+			139, 228, 159, 41, 0, 147, 162, 107, 223, 168, 243, 217, 223, 225,
+			67, 249, 45, 0, 45, 197, 246, 96, 206, 137, 17, 231, 32, 146,
+			9, 243, 62, 236, 237, 179, 1, 176, 227, 49, 78, 211, 151, 229,
+			99, 111, 52, 96, 129, 211, 219, 113, 236, 151, 32, 61, 16, 26,
+			67, 127, 178, 82, 234, 211, 24, 47, 62, 98, 223, 196, 120, 241,
+			17, 251, 6, 71, 108, 6, 70, 236, 155, 115, 231, 73, 8, 104,
+			169, 122, 102, 87, 237, 45, 103, 25, 173, 208, 253, 209, 192, 242,
+			150, 2, 102, 217, 160, 100, 4, 251, 58, 41, 99, 142, 17, 56,
+			137, 166, 3, 107, 31, 39, 67, 184, 239, 7, 17, 63, 191, 199,
+			43, 86, 192, 171, 244, 252, 65, 121, 101, 245, 230, 218, 173, 245,
+			79, 114, 133, 24, 60, 117, 90, 215, 118, 99, 240, 56, 105, 119,
+			113, 153, 153, 81, 85, 77, 215, 118, 47, 93, 150, 165, 89, 93,
+			235, 101, 202, 228, 60, 153, 133, 210, 63, 154, 157, 210, 181, 222,
+			116, 137, 12, 1, 122, 77, 207, 236, 169, 251, 203, 217, 93, 97,
+			155, 33, 66, 134, 219, 0, 80, 183, 85, 151, 44, 111, 0, 64,
+			82, 239, 188, 59, 218, 11, 75, 114, 61, 21, 86, 180, 101, 89,
+			44, 59, 97, 56, 98, 97, 217, 102, 145, 229, 184, 127, 195, 177,
+			63, 21, 192, 39, 28, 163, 77, 235, 218, 94, 12, 58, 23, 84,
+			246, 80, 126, 152, 81, 53, 77, 215, 246, 206, 207, 203, 210, 172,
+			174, 237, 199, 160, 107, 2, 244, 253, 233, 18, 249, 145, 70, 32,
+			81, 95, 56, 245, 43, 74, 246, 119, 53, 26, 171, 52, 210, 2,
+			88, 226, 110, 44, 103, 81, 79, 124, 38, 238, 125, 96, 77, 216,
+			198, 61, 194, 9, 9, 245, 209, 102, 213, 10, 101, 244, 190, 244,
+			150, 32, 92, 10, 123, 24, 92, 3, 183, 48, 47, 98, 47, 162,
+			177, 20, 184, 253, 136, 121, 98, 109, 117, 60, 12, 175, 22, 187,
+			158, 224, 169, 54, 29, 144, 28, 33, 130, 164, 222, 104, 55, 239,
+			137, 115, 176, 164, 181, 116, 15, 115, 134, 37, 155, 29, 148, 87,
+			86, 87, 11, 28, 194, 30, 70, 192, 7, 43, 118, 134, 247, 6,
+			34, 253, 34, 92, 232, 28, 56, 246, 200, 114, 193, 247, 37, 156,
+			12, 129, 96, 178, 200, 79, 18, 154, 11, 77, 126, 172, 0, 2,
+			52, 10, 24, 240, 65, 238, 153, 54, 11, 157, 64, 222, 48, 13,
+			172, 231, 44, 134, 4, 46, 162, 200, 233, 142, 112, 121, 58, 163,
+			232, 90, 56, 123, 129, 124, 67, 50, 153, 51, 124, 121, 58, 80,
+			175, 100, 191, 160, 149, 184, 55, 92, 16, 38, 80, 7, 206, 16,
+			241, 119, 194, 64, 62, 37, 154, 164, 190, 68, 30, 59, 3, 171,
+			210, 1, 242, 216, 25, 88, 149, 14, 112, 122, 156, 129, 85, 233,
+			224, 210, 101, 242, 19, 5, 64, 81, 116, 237, 88, 157, 207, 254,
+			155, 244, 170, 132, 205, 37, 221, 4, 99, 65, 238, 199, 200, 132,
+			86, 245, 158, 205, 2, 247, 8, 238, 106, 82, 181, 28, 8, 101,
+			57, 240, 195, 136, 174, 172, 11, 111, 121, 116, 214, 25, 79, 188,
+			46, 98, 186, 240, 13, 126, 159, 189, 176, 108, 214, 115, 6, 112,
+			243, 41, 110, 138, 253, 62, 94, 108, 222, 92, 165, 174, 127, 200,
+			2, 56, 52, 165, 190, 164, 189, 125, 43, 176, 122, 145, 72, 42,
+			34, 208, 228, 11, 216, 113, 76, 2, 190, 128, 29, 227, 2, 118,
+			6, 22, 176, 227, 115, 231, 201, 255, 154, 1, 175, 192, 153, 223,
+			82, 166, 254, 68, 81, 178, 127, 62, 67, 79, 105, 204, 32, 33,
+			191, 229, 120, 194, 179, 203, 179, 233, 225, 254, 145, 60, 209, 73,
+			255, 169, 67, 43, 36, 148, 97, 77, 102, 151, 226, 86, 152, 29,
+			103, 188, 102, 233, 175, 19, 39, 64, 207, 166, 182, 99, 115, 214,
+			34, 226, 46, 160, 47, 146, 17, 243, 233, 102, 245, 34, 97, 89,
+			199, 130, 129, 8, 38, 101, 197, 23, 126, 34, 145, 1, 132, 31,
+			178, 28, 151, 242, 229, 61, 96, 124, 42, 203, 247, 213, 58, 221,
+			101, 80, 96, 97, 36, 99, 219, 158, 16, 25, 64, 102, 136, 195,
+			179, 6, 204, 10, 249, 49, 81, 156, 52, 99, 50, 80, 246, 194,
+			17, 89, 247, 216, 145, 136, 0, 6, 217, 190, 109, 72, 10, 48,
+			110, 186, 45, 252, 75, 242, 174, 197, 121, 7, 79, 220, 17, 159,
+			35, 195, 128, 245, 152, 205, 60, 190, 26, 28, 176, 128, 10, 237,
+			188, 252, 166, 176, 201, 33, 65, 237, 34, 161, 19, 212, 135, 233,
+			135, 82, 117, 72, 104, 90, 109, 72, 104, 162, 174, 220, 185, 111,
+			52, 140, 150, 89, 141, 115, 73, 115, 176, 192, 226, 32, 140, 45,
+			1, 45, 15, 41, 76, 251, 160, 77, 164, 57, 180, 6, 170, 126,
+			145, 70, 62, 39, 18, 251, 7, 108, 73, 42, 171, 112, 20, 241,
+			78, 39, 20, 60, 114, 26, 58, 225, 65, 54, 1, 149, 216, 234,
+			59, 113, 87, 139, 155, 4, 97, 55, 225, 35, 225, 89, 40, 174,
+			172, 113, 112, 164, 254, 224, 68, 138, 25, 177, 82, 135, 116, 223,
+			217, 219, 79, 72, 15, 252, 132, 143, 146, 17, 40, 18, 78, 4,
+			167, 79, 29, 47, 100, 34, 24, 71, 226, 202, 89, 164, 67, 145,
+			174, 7, 141, 7, 45, 47, 237, 205, 152, 136, 189, 35, 72, 225,
+			75, 239, 91, 67, 236, 6, 82, 251, 186, 172, 31, 171, 143, 24,
+			8, 21, 73, 170, 35, 144, 53, 5, 22, 34, 48, 13, 228, 24,
+			249, 45, 101, 250, 50, 217, 64, 23, 209, 204, 63, 80, 212, 197,
+			236, 199, 180, 50, 62, 187, 164, 23, 158, 12, 215, 235, 132, 72,
+			118, 113, 175, 37, 156, 67, 121, 93, 42, 139, 42, 47, 126, 112,
+			157, 20, 209, 59, 52, 243, 219, 138, 122, 41, 251, 110, 162, 117,
+			227, 83, 16, 78, 64, 201, 156, 149, 109, 41, 226, 243, 121, 89,
+			84, 121, 241, 226, 2, 249, 59, 42, 186, 106, 102, 126, 79, 81,
+			207, 103, 255, 55, 196, 14, 26, 58, 17, 68, 8, 135, 21, 218,
+			163, 35, 79, 120, 10, 49, 27, 207, 136, 194, 242, 195, 243, 199,
+			230, 147, 12, 219, 74, 78, 235, 206, 196, 164, 198, 5, 1, 34,
+			120, 200, 109, 17, 188, 194, 115, 156, 36, 204, 206, 17, 185, 121,
+			197, 85, 144, 15, 57, 94, 225, 168, 215, 3, 107, 214, 34, 20,
+			123, 150, 215, 99, 174, 203, 108, 46, 192, 238, 91, 54, 164, 224,
+			0, 241, 93, 18, 88, 56, 32, 159, 208, 1, 134, 241, 126, 239,
+			30, 77, 34, 19, 167, 234, 239, 41, 106, 92, 4, 186, 156, 61,
+			71, 254, 165, 138, 46, 159, 153, 223, 231, 100, 250, 125, 149, 214,
+			140, 237, 150, 81, 229, 243, 178, 36, 116, 220, 113, 90, 7, 100,
+			237, 84, 44, 236, 52, 36, 28, 120, 136, 50, 103, 167, 250, 31,
+			39, 25, 164, 92, 70, 166, 34, 233, 24, 112, 242, 136, 234, 7,
+			244, 158, 227, 217, 102, 194, 76, 150, 103, 185, 71, 161, 19, 138,
+			243, 18, 174, 227, 92, 224, 159, 216, 5, 244, 0, 6, 36, 56,
+			255, 38, 245, 82, 198, 46, 192, 85, 72, 38, 33, 239, 159, 120,
+			43, 19, 129, 225, 64, 241, 85, 49, 76, 77, 202, 152, 174, 154,
+			2, 148, 139, 139, 42, 47, 158, 61, 71, 126, 114, 6, 221, 69,
+			51, 127, 164, 168, 231, 178, 63, 62, 115, 138, 174, 147, 214, 26,
+			204, 23, 117, 146, 172, 41, 100, 69, 120, 75, 63, 33, 153, 220,
+			250, 83, 132, 234, 164, 0, 199, 108, 61, 224, 172, 233, 196, 72,
+			74, 185, 1, 94, 241, 13, 147, 51, 14, 65, 162, 229, 195, 130,
+			116, 166, 151, 105, 51, 83, 179, 97, 92, 17, 123, 178, 175, 196,
+			127, 202, 167, 214, 129, 239, 216, 180, 207, 152, 189, 107, 245, 158,
+			83, 215, 247, 165, 181, 2, 35, 167, 192, 150, 89, 220, 133, 192,
+			130, 96, 22, 49, 62, 182, 216, 173, 36, 236, 145, 79, 228, 38,
+			192, 215, 229, 52, 112, 92, 188, 16, 97, 48, 98, 58, 133, 163,
+			222, 126, 210, 139, 112, 235, 225, 48, 137, 176, 125, 189, 231, 34,
+			2, 41, 200, 222, 194, 123, 87, 176, 122, 66, 206, 196, 122, 79,
+			216, 20, 56, 209, 8, 3, 100, 178, 36, 160, 40, 71, 117, 147,
+			196, 32, 114, 17, 181, 7, 233, 115, 112, 215, 234, 143, 220, 24,
+			101, 191, 31, 239, 32, 4, 61, 252, 199, 162, 206, 87, 235, 194,
+			53, 64, 134, 250, 14, 88, 207, 25, 162, 95, 35, 242, 1, 117,
+			162, 18, 161, 149, 196, 171, 185, 24, 119, 237, 249, 96, 16, 10,
+			243, 84, 0, 145, 22, 16, 193, 207, 23, 1, 58, 217, 194, 152,
+			32, 25, 166, 26, 74, 47, 39, 167, 171, 224, 182, 204, 31, 13,
+			68, 140, 141, 154, 72, 16, 3, 250, 183, 192, 242, 66, 161, 142,
+			134, 99, 8, 78, 189, 200, 143, 225, 229, 60, 134, 228, 32, 233,
+			177, 44, 166, 11, 161, 32, 6, 240, 6, 54, 113, 34, 2, 40,
+			25, 11, 249, 245, 138, 170, 49, 91, 129, 19, 24, 108, 152, 248,
+			136, 136, 216, 11, 124, 79, 7, 193, 17, 59, 73, 211, 192, 9,
+			163, 100, 7, 203, 40, 48, 177, 231, 100, 81, 229, 197, 55, 207,
+			146, 191, 84, 209, 209, 58, 243, 99, 69, 125, 59, 251, 95, 84,
+			218, 22, 249, 55, 78, 239, 53, 161, 72, 206, 231, 239, 98, 78,
+			64, 8, 103, 144, 18, 247, 248, 52, 140, 229, 137, 34, 161, 35,
+			79, 132, 166, 178, 105, 181, 158, 15, 11, 37, 154, 151, 135, 184,
+			112, 180, 183, 199, 66, 48, 70, 65, 238, 133, 51, 45, 202, 147,
+			40, 180, 19, 10, 153, 28, 28, 15, 98, 69, 162, 72, 143, 177,
+			76, 251, 14, 76, 60, 161, 216, 196, 96, 141, 152, 24, 1, 78,
+			141, 112, 69, 9, 71, 133, 190, 213, 19, 169, 69, 92, 231, 57,
+			19, 87, 3, 44, 20, 57, 26, 64, 255, 231, 123, 192, 190, 7,
+			225, 196, 119, 36, 5, 64, 96, 121, 189, 125, 22, 150, 10, 132,
+			86, 134, 34, 105, 93, 228, 79, 162, 82, 154, 46, 229, 234, 23,
+			188, 169, 16, 125, 236, 145, 250, 211, 10, 144, 251, 146, 44, 170,
+			188, 120, 53, 75, 254, 185, 244, 25, 207, 252, 123, 69, 205, 102,
+			255, 201, 119, 24, 12, 46, 163, 157, 4, 150, 160, 205, 38, 228,
+			54, 8, 35, 80, 110, 57, 169, 188, 13, 35, 47, 148, 55, 222,
+			232, 223, 30, 74, 217, 155, 111, 217, 249, 123, 124, 247, 227, 11,
+			5, 196, 73, 242, 251, 16, 147, 51, 146, 90, 102, 33, 174, 134,
+			98, 247, 234, 187, 214, 115, 199, 61, 18, 66, 169, 60, 239, 58,
+			97, 132, 42, 24, 191, 215, 27, 5, 48, 199, 78, 35, 242, 215,
+			65, 208, 25, 5, 72, 182, 32, 139, 42, 47, 94, 121, 75, 198,
+			135, 56, 163, 103, 254, 163, 162, 46, 100, 255, 74, 137, 183, 41,
+			49, 202, 192, 78, 129, 3, 96, 38, 49, 177, 195, 136, 134, 163,
+			221, 148, 106, 187, 90, 47, 208, 161, 21, 66, 246, 40, 220, 65,
+			122, 50, 134, 154, 220, 144, 33, 158, 190, 112, 53, 14, 145, 145,
+			123, 190, 235, 202, 160, 187, 137, 18, 128, 58, 125, 18, 119, 20,
+			226, 181, 49, 28, 248, 153, 7, 185, 55, 133, 54, 100, 96, 217,
+			44, 129, 13, 204, 72, 165, 246, 67, 236, 71, 22, 198, 90, 226,
+			228, 10, 28, 16, 213, 93, 9, 188, 19, 65, 6, 51, 36, 199,
+			25, 5, 240, 63, 39, 139, 42, 47, 94, 184, 72, 126, 44, 168,
+			51, 171, 103, 254, 147, 162, 94, 201, 254, 161, 242, 157, 100, 35,
+			208, 233, 211, 129, 213, 219, 119, 60, 182, 148, 100, 23, 224, 168,
+			200, 157, 121, 242, 134, 140, 185, 46, 14, 44, 199, 21, 150, 130,
+			1, 23, 34, 209, 89, 62, 182, 133, 38, 99, 39, 83, 43, 9,
+			88, 156, 146, 251, 83, 57, 234, 57, 34, 10, 96, 34, 37, 235,
+			89, 149, 23, 47, 94, 38, 255, 67, 33, 106, 102, 86, 159, 249,
+			83, 101, 234, 191, 43, 74, 246, 79, 20, 106, 158, 74, 8, 37,
+			4, 53, 112, 3, 131, 254, 154, 61, 97, 97, 81, 164, 78, 116,
+			35, 20, 249, 52, 248, 174, 33, 35, 129, 185, 163, 158, 83, 146,
+			215, 228, 210, 242, 70, 90, 238, 96, 66, 211, 67, 118, 195, 22,
+			235, 78, 228, 139, 208, 205, 34, 13, 134, 59, 225, 210, 115, 40,
+			179, 6, 192, 119, 240, 4, 214, 51, 199, 117, 162, 35, 140, 244,
+			119, 200, 64, 19, 37, 226, 114, 30, 176, 96, 143, 81, 219, 63,
+			244, 228, 190, 213, 123, 142, 1, 57, 57, 29, 254, 84, 153, 93,
+			32, 55, 72, 38, 51, 203, 207, 61, 127, 166, 168, 122, 246, 45,
+			97, 39, 33, 195, 51, 194, 56, 163, 254, 232, 44, 153, 230, 31,
+			78, 195, 151, 179, 178, 168, 240, 226, 220, 89, 89, 212, 120, 113,
+			254, 2, 121, 6, 173, 42, 122, 230, 207, 21, 245, 90, 182, 133,
+			151, 230, 241, 212, 113, 226, 75, 112, 188, 94, 146, 177, 34, 177,
+			179, 36, 15, 67, 192, 134, 62, 125, 216, 109, 119, 198, 194, 221,
+			196, 224, 40, 51, 208, 197, 57, 89, 132, 30, 207, 95, 145, 69,
+			141, 23, 179, 111, 147, 101, 162, 102, 230, 244, 153, 191, 80, 166,
+			254, 175, 162, 100, 115, 73, 50, 172, 56, 90, 97, 24, 141, 101,
+			60, 2, 26, 205, 41, 122, 230, 47, 56, 141, 254, 179, 66, 50,
+			153, 57, 78, 164, 159, 114, 34, 253, 177, 66, 239, 59, 145, 227,
+			178, 144, 118, 91, 117, 220, 97, 210, 10, 39, 121, 36, 24, 250,
+			66, 83, 62, 176, 68, 120, 161, 84, 173, 205, 88, 183, 120, 119,
+			223, 15, 163, 207, 202, 119, 81, 105, 254, 217, 152, 193, 119, 162,
+			238, 141, 53, 189, 66, 23, 41, 116, 123, 160, 151, 142, 149, 190,
+			97, 208, 203, 17, 97, 144, 41, 79, 110, 160, 31, 200, 149, 246,
+			156, 40, 87, 34, 180, 253, 160, 217, 173, 215, 38, 81, 114, 14,
+			6, 246, 167, 114, 96, 231, 96, 96, 127, 42, 7, 118, 14, 6,
+			246, 167, 124, 96, 255, 167, 32, 133, 162, 103, 254, 15, 95, 15,
+			254, 76, 161, 141, 148, 174, 16, 179, 133, 189, 98, 172, 241, 218,
+			0, 98, 130, 202, 192, 25, 67, 63, 116, 34, 63, 56, 42, 74,
+			123, 84, 43, 136, 16, 234, 114, 57, 23, 139, 169, 155, 188, 152,
+			36, 72, 41, 15, 173, 35, 136, 96, 82, 238, 249, 1, 147, 165,
+			29, 140, 195, 177, 195, 231, 215, 206, 40, 114, 220, 157, 145, 199,
+			183, 174, 48, 42, 245, 128, 52, 169, 27, 240, 91, 43, 171, 37,
+			34, 56, 75, 198, 122, 226, 146, 115, 232, 90, 33, 223, 193, 9,
+			109, 197, 33, 143, 144, 6, 202, 52, 32, 45, 41, 164, 0, 13,
+			230, 116, 89, 212, 120, 241, 210, 101, 242, 75, 64, 32, 85, 207,
+			252, 76, 81, 47, 100, 87, 105, 211, 99, 24, 191, 31, 182, 93,
+			76, 31, 241, 45, 68, 194, 6, 185, 188, 245, 51, 105, 39, 59,
+			7, 103, 221, 159, 41, 179, 111, 202, 162, 198, 139, 231, 231, 9,
+			35, 92, 84, 152, 249, 85, 117, 234, 215, 85, 37, 251, 152, 158,
+			48, 80, 195, 67, 5, 222, 42, 31, 176, 192, 118, 184, 248, 28,
+			159, 36, 14, 247, 153, 12, 68, 112, 4, 39, 243, 212, 134, 79,
+			228, 142, 143, 186, 18, 142, 241, 175, 170, 211, 11, 160, 43, 81,
+			248, 116, 248, 91, 170, 250, 65, 246, 99, 90, 75, 7, 86, 41,
+			197, 38, 80, 150, 235, 142, 119, 139, 171, 48, 216, 6, 241, 186,
+			239, 202, 162, 202, 139, 239, 231, 72, 19, 26, 86, 244, 204, 223,
+			86, 213, 75, 217, 138, 8, 7, 47, 149, 94, 227, 24, 128, 2,
+			205, 246, 189, 27, 168, 146, 153, 32, 168, 196, 221, 41, 162, 197,
+			121, 89, 84, 121, 241, 226, 2, 49, 161, 59, 85, 207, 252, 154,
+			170, 94, 201, 222, 121, 109, 119, 175, 237, 8, 226, 186, 170, 170,
+			46, 139, 208, 244, 165, 203, 177, 49, 243, 31, 46, 144, 219, 175,
+			50, 241, 147, 25, 179, 203, 150, 61, 112, 60, 180, 248, 131, 223,
+			104, 215, 124, 89, 218, 112, 202, 47, 75, 240, 54, 251, 246, 73,
+			19, 102, 8, 28, 131, 246, 205, 223, 205, 118, 58, 247, 175, 20,
+			242, 150, 241, 98, 232, 7, 17, 223, 171, 208, 174, 40, 108, 137,
+			121, 165, 47, 144, 233, 128, 89, 174, 52, 83, 21, 5, 253, 3,
+			114, 182, 231, 250, 35, 123, 7, 87, 48, 52, 12, 125, 19, 30,
+			226, 221, 142, 126, 149, 156, 225, 179, 50, 100, 104, 41, 219, 146,
+			69, 222, 40, 136, 54, 194, 74, 182, 37, 10, 250, 26, 33, 145,
+			51, 96, 59, 96, 143, 119, 117, 6, 172, 88, 47, 141, 89, 177,
+			74, 163, 236, 214, 92, 36, 127, 174, 126, 143, 76, 87, 56, 77,
+			116, 139, 232, 167, 209, 208, 87, 74, 147, 73, 88, 122, 37, 202,
+			217, 203, 167, 44, 181, 13, 78, 221, 220, 212, 214, 250, 211, 181,
+			239, 50, 148, 119, 224, 247, 112, 247, 243, 31, 205, 147, 25, 61,
+			147, 153, 90, 255, 255, 212, 108, 243, 189, 196, 108, 115, 49, 49,
+			219, 252, 36, 54, 219, 252, 229, 196, 108, 243, 151, 201, 95, 42,
+			68, 157, 153, 210, 51, 23, 166, 222, 134, 80, 231, 48, 58, 212,
+			31, 10, 5, 191, 60, 46, 243, 51, 73, 100, 57, 30, 95, 167,
+			48, 208, 216, 19, 127, 36, 60, 144, 96, 17, 217, 103, 180, 178,
+			109, 134, 232, 106, 212, 218, 174, 82, 227, 197, 208, 245, 3, 22,
+			108, 18, 250, 17, 77, 246, 72, 127, 24, 46, 225, 40, 44, 217,
+			236, 160, 100, 13, 135, 225, 208, 143, 96, 159, 12, 134, 61, 134,
+			181, 202, 24, 25, 43, 44, 99, 6, 252, 131, 87, 54, 243, 115,
+			54, 49, 12, 124, 27, 238, 216, 102, 166, 20, 93, 187, 48, 123,
+			150, 252, 72, 35, 153, 153, 41, 97, 109, 249, 40, 251, 143, 53,
+			122, 154, 201, 104, 20, 56, 123, 123, 28, 235, 73, 239, 172, 240,
+			121, 136, 129, 159, 252, 32, 130, 245, 136, 72, 195, 183, 56, 148,
+			115, 98, 34, 35, 195, 103, 81, 19, 66, 215, 14, 124, 15, 228,
+			255, 176, 72, 119, 191, 47, 219, 72, 194, 58, 219, 190, 199, 168,
+			53, 138, 124, 46, 225, 138, 3, 216, 238, 17, 237, 5, 190, 71,
+			191, 231, 239, 202, 19, 12, 36, 146, 71, 101, 155, 180, 85, 1,
+			253, 82, 223, 113, 93, 113, 146, 17, 23, 25, 152, 140, 223, 73,
+			84, 120, 237, 161, 229, 121, 144, 93, 145, 208, 45, 103, 239, 139,
+			17, 11, 142, 74, 212, 76, 114, 141, 29, 250, 193, 115, 25, 99,
+			75, 198, 89, 3, 148, 97, 68, 120, 211, 120, 25, 18, 107, 51,
+			98, 211, 80, 80, 12, 248, 158, 144, 22, 152, 93, 162, 102, 95,
+			168, 196, 226, 118, 2, 7, 48, 79, 73, 195, 150, 109, 83, 203,
+			195, 84, 44, 130, 13, 43, 219, 166, 8, 182, 128, 81, 181, 102,
+			132, 137, 111, 118, 230, 170, 44, 169, 186, 150, 125, 107, 85, 150,
+			52, 93, 203, 126, 218, 2, 43, 191, 41, 61, 243, 14, 159, 193,
+			210, 224, 248, 157, 217, 247, 73, 77, 26, 28, 191, 167, 94, 204,
+			126, 34, 174, 182, 91, 124, 133, 44, 209, 14, 236, 37, 114, 232,
+			164, 115, 19, 44, 159, 112, 184, 19, 195, 3, 155, 255, 155, 210,
+			115, 72, 123, 15, 239, 13, 5, 84, 239, 161, 77, 158, 128, 227,
+			189, 11, 58, 57, 144, 134, 199, 215, 213, 183, 179, 78, 76, 100,
+			140, 39, 49, 206, 56, 105, 190, 137, 53, 156, 226, 67, 16, 129,
+			224, 210, 103, 87, 196, 104, 75, 204, 173, 4, 128, 147, 76, 172,
+			192, 141, 71, 187, 30, 67, 168, 112, 48, 230, 46, 167, 172, 148,
+			175, 191, 149, 37, 111, 0, 132, 170, 174, 45, 162, 225, 36, 132,
+			66, 215, 22, 227, 106, 28, 250, 197, 185, 121, 89, 210, 116, 109,
+			241, 226, 2, 86, 211, 116, 237, 67, 17, 223, 44, 3, 241, 193,
+			181, 15, 227, 106, 124, 201, 249, 48, 166, 135, 198, 191, 188, 160,
+			147, 63, 152, 134, 122, 25, 93, 187, 165, 126, 152, 253, 205, 12,
+			120, 194, 165, 12, 197, 247, 241, 236, 45, 206, 206, 41, 146, 83,
+			3, 253, 116, 64, 67, 89, 183, 196, 1, 57, 158, 42, 253, 145,
+			235, 210, 125, 127, 196, 151, 95, 179, 196, 74, 66, 86, 74, 222,
+			47, 111, 46, 47, 23, 233, 202, 230, 242, 50, 45, 149, 74, 132,
+			54, 227, 20, 82, 240, 97, 162, 211, 27, 121, 194, 195, 19, 167,
+			110, 170, 93, 140, 116, 42, 38, 19, 36, 148, 9, 252, 67, 72,
+			111, 101, 197, 41, 118, 132, 31, 155, 140, 172, 1, 78, 184, 161,
+			243, 3, 88, 227, 33, 46, 148, 15, 247, 43, 187, 71, 4, 6,
+			28, 199, 123, 247, 251, 136, 103, 80, 194, 67, 107, 195, 63, 192,
+			67, 107, 220, 15, 166, 117, 8, 233, 10, 128, 195, 103, 166, 8,
+			228, 206, 201, 149, 216, 116, 37, 253, 67, 102, 134, 80, 106, 17,
+			225, 26, 66, 84, 21, 202, 83, 190, 106, 0, 214, 225, 190, 21,
+			216, 200, 235, 188, 30, 145, 153, 177, 100, 12, 174, 112, 96, 185,
+			46, 250, 194, 138, 173, 94, 184, 31, 99, 7, 8, 15, 100, 252,
+			237, 237, 51, 123, 228, 50, 242, 234, 165, 18, 20, 29, 152, 95,
+			19, 150, 70, 108, 220, 247, 184, 132, 182, 250, 27, 74, 138, 198,
+			104, 163, 38, 124, 110, 147, 224, 138, 210, 250, 36, 153, 161, 176,
+			158, 148, 232, 86, 156, 247, 197, 9, 73, 130, 32, 166, 130, 73,
+			55, 21, 248, 131, 73, 243, 134, 178, 23, 232, 8, 7, 42, 41,
+			193, 185, 153, 25, 206, 171, 114, 214, 100, 20, 93, 187, 117, 229,
+			125, 89, 210, 116, 237, 214, 245, 69, 41, 58, 254, 191, 0, 0,
+			0, 255, 255, 18, 245, 99, 70, 6, 43, 1, 0},
+	)
+}
+
+// FileDescriptorSet returns a descriptor set for this proto package, which
+// includes all defined services, and all transitive dependencies.
+//
+// Will not return nil.
+//
+// Do NOT modify the returned descriptor.
+func FileDescriptorSet() *descriptorpb.FileDescriptorSet {
+	// We just need ONE of the service names to look up the FileDescriptorSet.
+	ret, err := discovery.GetDescriptorSet("weetbix.internal.admin.Admin")
+	if err != nil {
+		panic(err)
+	}
+	return ret
+}
diff --git a/analysis/internal/admin/server.go b/analysis/internal/admin/server.go
new file mode 100644
index 0000000..de14d21
--- /dev/null
+++ b/analysis/internal/admin/server.go
@@ -0,0 +1,212 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package admin
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"google.golang.org/grpc/codes"
+	"google.golang.org/protobuf/types/known/emptypb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/grpc/appstatus"
+	"go.chromium.org/luci/server/auth"
+
+	adminpb "go.chromium.org/luci/analysis/internal/admin/proto"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/services/testvariantbqexporter"
+	"go.chromium.org/luci/analysis/pbutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// allowGroup is a Chrome Infra Auth group, members of which are allowed to call
+// admin API.
+const allowGroup = "service-chops-weetbix-admins"
+
+// adminServer implements adminpb.AdminServer.
+type adminServer struct {
+	adminpb.UnimplementedAdminServer
+}
+
+// CreateServer creates an adminServer.
+func CreateServer() *adminServer {
+	return &adminServer{}
+}
+
+func unspecified(field string) error {
+	return fmt.Errorf("%s is not specified", field)
+}
+
+func bqExportFromConfig(ctx context.Context, realm, cloudProject, dataset, table string) (*configpb.BigQueryExport, error) {
+	rc, err := config.Realm(ctx, realm)
+	if err != nil {
+		return nil, err
+	}
+	for _, bqexport := range rc.GetTestVariantAnalysis().GetBqExports() {
+		tableC := bqexport.GetTable()
+		if tableC == nil {
+			continue
+		}
+		if tableC.GetCloudProject() == cloudProject && tableC.GetDataset() == dataset && tableC.GetTable() == table {
+			// The table in request is found in config.
+			return bqexport, nil
+		}
+	}
+	return nil, fmt.Errorf("table not found in realm config")
+}
+
+func validateTable(ctx context.Context, realm, cloudProject, dataset, table string) error {
+	switch {
+	case cloudProject == "":
+		return unspecified("cloud project")
+	case dataset == "":
+		return unspecified("dataset")
+	case table == "":
+		return unspecified("table")
+	}
+
+	_, err := bqExportFromConfig(ctx, realm, cloudProject, dataset, table)
+	return err
+}
+
+func validateTimeRange(ctx context.Context, timeRange *pb.TimeRange) error {
+	switch {
+	case timeRange.GetEarliest() == nil:
+		return unspecified("timeRange.Earliest")
+	case timeRange.GetLatest() == nil:
+		return unspecified("timeRange.Latest")
+	}
+
+	earliest, err := pbutil.AsTime(timeRange.Earliest)
+	if err != nil {
+		return err
+	}
+
+	latest, err := pbutil.AsTime(timeRange.Latest)
+	if err != nil {
+		return err
+	}
+
+	if !earliest.Before(latest) {
+		return fmt.Errorf("timeRange: earliest must be before latest")
+	}
+
+	if !latest.Before(clock.Now(ctx)) {
+		return fmt.Errorf("timeRange: latest must not be in the future")
+	}
+	return nil
+}
+
+func validateExportTestVariantsRequest(ctx context.Context, req *adminpb.ExportTestVariantsRequest) error {
+	if req.GetRealm() == "" {
+		return unspecified("realm")
+	}
+	if err := validateTable(ctx, req.Realm, req.GetCloudProject(), req.GetDataset(), req.GetTable()); err != nil {
+		return err
+	}
+
+	return validateTimeRange(ctx, req.GetTimeRange())
+}
+
+type rangeInTime struct {
+	start time.Time
+	end   time.Time
+}
+
+// splitTimeRange split the given time range to a slice of smaller ranges,
+// each is testvariantbqexporter.BqExportJobInterval long.
+func splitTimeRange(timeRange *pb.TimeRange) ([]rangeInTime, error) {
+	earliest, err := pbutil.AsTime(timeRange.Earliest)
+	if err != nil {
+		return nil, err
+	}
+	latest, err := pbutil.AsTime(timeRange.Latest)
+	if err != nil {
+		return nil, err
+	}
+
+	// Truncate both ends to full hour.
+	earliest = earliest.Truncate(time.Hour)
+	latest = latest.Truncate(time.Hour)
+
+	// Split the time range to a slice of sub ranges - each is BqExportJobInterval long.
+	delta := latest.Sub(earliest)
+	subRangeNum := int(delta / testvariantbqexporter.BqExportJobInterval)
+	ranges := make([]rangeInTime, subRangeNum)
+	start := earliest
+	for i := 0; i < subRangeNum; i++ {
+		end := start.Add(testvariantbqexporter.BqExportJobInterval)
+		if end.After(latest) {
+			end = latest
+		}
+		ranges[i] = rangeInTime{
+			start: start,
+			end:   end,
+		}
+		start = end
+	}
+	return ranges, nil
+}
+
+// ExportTestVariants implements AdminServer.
+func (a *adminServer) ExportTestVariants(ctx context.Context, req *adminpb.ExportTestVariantsRequest) (*emptypb.Empty, error) {
+	if err := checkAllowed(ctx, "ExportTestVariants"); err != nil {
+		return nil, err
+	}
+
+	if err := validateExportTestVariantsRequest(ctx, req); err != nil {
+		return nil, appstatus.BadRequest(err)
+	}
+
+	subRanges, err := splitTimeRange(req.TimeRange)
+	if err != nil {
+		return nil, err
+	}
+
+	bqExport, err := bqExportFromConfig(ctx, req.Realm, req.CloudProject, req.Dataset, req.Table)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, r := range subRanges {
+		err := testvariantbqexporter.Schedule(ctx, req.Realm, req.CloudProject, req.Dataset, req.Table, bqExport.GetPredicate(), &pb.TimeRange{
+			Earliest: timestamppb.New(r.start),
+			Latest:   timestamppb.New(r.end),
+		})
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return &emptypb.Empty{}, nil
+}
+
+func checkAllowed(ctx context.Context, name string) error {
+	switch yes, err := auth.IsMember(ctx, allowGroup); {
+	case err != nil:
+		return errors.Annotate(err, "failed to check ACL").Err()
+	case !yes:
+		return appstatus.Errorf(codes.PermissionDenied, "not a member of %s", allowGroup)
+	default:
+		logging.Warningf(ctx, "%s is calling admin.%s", auth.CurrentIdentity(ctx), name)
+		return nil
+	}
+}
diff --git a/analysis/internal/admin/server_test.go b/analysis/internal/admin/server_test.go
new file mode 100644
index 0000000..43ba3b1
--- /dev/null
+++ b/analysis/internal/admin/server_test.go
@@ -0,0 +1,332 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package admin
+
+import (
+	"context"
+	"sort"
+	"testing"
+	"time"
+
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/tq"
+
+	adminpb "go.chromium.org/luci/analysis/internal/admin/proto"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/services/testvariantbqexporter"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func init() {
+	testvariantbqexporter.RegisterTaskClass()
+}
+
+func createProjectsConfig() map[string]*configpb.ProjectConfig {
+	return map[string]*configpb.ProjectConfig{
+		"chromium": {
+			Realms: []*configpb.RealmConfig{
+				{
+					Name: "try",
+					TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+						BqExports: []*configpb.BigQueryExport{
+							{
+								Table: &configpb.BigQueryExport_BigQueryTable{
+									CloudProject: "cloudProject",
+									Dataset:      "dataset",
+									Table:        "table",
+								},
+								Predicate: &atvpb.Predicate{
+									Status: atvpb.Status_FLAKY,
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func TestCheckAllowed(t *testing.T) {
+	t.Parallel()
+
+	Convey("with access", t, func() {
+		ctx := auth.WithState(context.Background(), &authtest.FakeState{
+			Identity:       "user:admin@example.com",
+			IdentityGroups: []string{allowGroup},
+		})
+		So(checkAllowed(ctx, ""), ShouldBeNil)
+	})
+
+	Convey("no login", t, func() {
+		ctx := auth.WithState(context.Background(), &authtest.FakeState{
+			Identity: "anonymous:anonymous",
+		})
+		So(checkAllowed(ctx, ""), ShouldErrLike, "not a member of service-chops-weetbix-admins")
+	})
+
+	Convey("without access", t, func() {
+		ctx := auth.WithState(context.Background(), &authtest.FakeState{
+			Identity:       "user:user@example.com",
+			IdentityGroups: []string{"unrelated_group"},
+		})
+		So(checkAllowed(ctx, ""), ShouldErrLike, "not a member of service-chops-weetbix-admins")
+	})
+}
+
+func TestValidateExportTestVariantsRequest(t *testing.T) {
+	t.Parallel()
+	Convey("TestValidateExportTestVariantsRequest", t, func() {
+		ctx := context.Background()
+		ctx = memory.Use(ctx)
+		config.SetTestProjectConfig(ctx, createProjectsConfig())
+		realm := "chromium:try"
+		cloudProject := "cloudProject"
+		dataset := "dataset"
+		table := "table"
+		start := time.Date(2021, 11, 12, 0, 0, 0, 0, time.UTC)
+		end := start.Add(24 * time.Hour)
+
+		Convey("pass", func() {
+			err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+				Realm:        realm,
+				CloudProject: cloudProject,
+				Dataset:      dataset,
+				Table:        table,
+				TimeRange: &pb.TimeRange{
+					Earliest: timestamppb.New(start),
+					Latest:   timestamppb.New(end),
+				},
+			})
+			So(err, ShouldBeNil)
+		})
+
+		Convey("no realm", func() {
+			err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{})
+			So(err, ShouldErrLike, "realm is not specified")
+		})
+
+		Convey("table", func() {
+			Convey("no cloud project", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm: realm,
+				})
+				So(err, ShouldErrLike, "cloud project is not specified")
+			})
+
+			Convey("no dataset", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+				})
+				So(err, ShouldErrLike, "dataset is not specified")
+			})
+
+			Convey("no table", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+					Dataset:      dataset,
+				})
+				So(err, ShouldErrLike, "table is not specified")
+			})
+
+			Convey("unknown table", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+					Dataset:      dataset,
+					Table:        "unknown",
+				})
+				So(err, ShouldErrLike, "table not found in realm config")
+			})
+		})
+
+		Convey("time range", func() {
+			Convey("no earliest", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+					Dataset:      dataset,
+					Table:        table,
+					TimeRange:    &pb.TimeRange{},
+				})
+				So(err, ShouldErrLike, "timeRange.Earliest is not specified")
+			})
+
+			Convey("no latest", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+					Dataset:      dataset,
+					Table:        table,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(start),
+					},
+				})
+				So(err, ShouldErrLike, "timeRange.Latest is not specified")
+			})
+
+			Convey("earliest is after latest", func() {
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+					Dataset:      dataset,
+					Table:        table,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(end),
+						Latest:   timestamppb.New(start),
+					},
+				})
+				So(err, ShouldErrLike, "timeRange: earliest must be before latest")
+			})
+
+			Convey("latest is in the future", func() {
+				now := clock.Now(ctx)
+				err := validateExportTestVariantsRequest(ctx, &adminpb.ExportTestVariantsRequest{
+					Realm:        realm,
+					CloudProject: cloudProject,
+					Dataset:      dataset,
+					Table:        table,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(start),
+						Latest:   timestamppb.New(now.Add(time.Hour)),
+					},
+				})
+				So(err, ShouldErrLike, "timeRange: latest must not be in the future")
+			})
+		})
+	})
+}
+
+func TestSplitTimeRange(t *testing.T) {
+	t.Parallel()
+	start := time.Date(2021, 11, 12, 0, 1, 0, 0, time.UTC)
+	Convey("earliest and latest too close", t, func() {
+		end := start.Add(time.Minute)
+		timeRange := &pb.TimeRange{
+			Earliest: timestamppb.New(start),
+			Latest:   timestamppb.New(end),
+		}
+		ranges, err := splitTimeRange(timeRange)
+		So(err, ShouldBeNil)
+		So(len(ranges), ShouldEqual, 0)
+	})
+
+	Convey("split", t, func() {
+		end := start.Add(2 * time.Hour)
+		timeRange := &pb.TimeRange{
+			Earliest: timestamppb.New(start),
+			Latest:   timestamppb.New(end),
+		}
+		ranges, err := splitTimeRange(timeRange)
+		So(err, ShouldBeNil)
+		So(len(ranges), ShouldEqual, 2)
+
+		start0 := start.Truncate(time.Hour)
+		exp := []rangeInTime{
+			{
+				start: start0,
+				end:   start0.Add(time.Hour),
+			},
+			{
+				start: start0.Add(time.Hour),
+				end:   start0.Add(2 * time.Hour),
+			},
+		}
+		So(ranges, ShouldResemble, exp)
+	})
+}
+
+func TestExportTestVariants(t *testing.T) {
+	ctx, skdr := tq.TestingContext(context.Background(), nil)
+	ctx = auth.WithState(ctx, &authtest.FakeState{
+		Identity:       "user:admin@example.com",
+		IdentityGroups: []string{allowGroup},
+	})
+	ctx = memory.Use(ctx)
+	config.SetTestProjectConfig(ctx, createProjectsConfig())
+
+	realm := "chromium:try"
+	cloudProject := "cloudProject"
+	dataset := "dataset"
+	table := "table"
+	start := time.Date(2021, 11, 12, 0, 0, 0, 0, time.UTC)
+	end := start.Add(2 * time.Hour)
+	req := &adminpb.ExportTestVariantsRequest{
+		Realm:        realm,
+		CloudProject: cloudProject,
+		Dataset:      dataset,
+		Table:        table,
+		TimeRange: &pb.TimeRange{
+			Earliest: timestamppb.New(start),
+			Latest:   timestamppb.New(end),
+		},
+	}
+	Convey("ExportTestVariants", t, func() {
+		a := CreateServer()
+		_, err := a.ExportTestVariants(ctx, req)
+		So(err, ShouldBeNil)
+		So(len(skdr.Tasks().Payloads()), ShouldEqual, 2)
+		tasks := []*taskspb.ExportTestVariants{
+			{
+				Realm:        realm,
+				CloudProject: cloudProject,
+				Dataset:      dataset,
+				Table:        table,
+				Predicate: &atvpb.Predicate{
+					Status: atvpb.Status_FLAKY,
+				},
+				TimeRange: &pb.TimeRange{
+					Earliest: timestamppb.New(start),
+					Latest:   timestamppb.New(start.Add(time.Hour)),
+				},
+			},
+			{
+				Realm:        realm,
+				CloudProject: cloudProject,
+				Dataset:      dataset,
+				Table:        table,
+				Predicate: &atvpb.Predicate{
+					Status: atvpb.Status_FLAKY,
+				},
+				TimeRange: &pb.TimeRange{
+					Earliest: timestamppb.New(start.Add(time.Hour)),
+					Latest:   timestamppb.New(start.Add(2 * time.Hour)),
+				},
+			},
+		}
+
+		payloads := skdr.Tasks().Payloads()
+		sort.Slice(payloads, func(i, j int) bool {
+			taski := payloads[i].(*taskspb.ExportTestVariants)
+			taskj := payloads[j].(*taskspb.ExportTestVariants)
+			return taski.TimeRange.Earliest.AsTime().Before(taskj.TimeRange.Earliest.AsTime())
+		})
+
+		So(payloads, ShouldResembleProto, tasks)
+	})
+}
diff --git a/analysis/internal/aip/datamodel.go b/analysis/internal/aip/datamodel.go
new file mode 100644
index 0000000..55a274a
--- /dev/null
+++ b/analysis/internal/aip/datamodel.go
@@ -0,0 +1,92 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package aip contains utilities used to comply with API Improvement
+// Proposals (AIPs) from https://google.aip.dev/. This includes
+// an AIP-160 filter parser and SQL generator and AIP-132 order by
+// clause parser and SQL generator.
+package aip
+
+import (
+	"fmt"
+	"strings"
+)
+
+// Column represents the schema of a Database column.
+type Column struct {
+	// The externally-visible name of the column. This may be used in AIP-160
+	// filters and order by clauses.
+	name string
+
+	// The database name of the column.
+	// Important: Only assign assign safe constants to this field.
+	// User input MUST NOT flow to this field, as it will be used directly
+	// in SQL statements and would allow the user to perform SQL injection
+	// attacks.
+	databaseName string
+
+	// Whether this column can be sorted on.
+	sortable bool
+
+	// Whether this column can be filtered on.
+	filterable bool
+
+	// ImplicitFilter controls whether this field is searched implicitly
+	// in AIP-160 filter expressions.
+	implicitFilter bool
+}
+
+// Table represents the schema of a Database table, view or query.
+type Table struct {
+	// The columns in the database table.
+	columns []*Column
+
+	// A mapping from externally-visible column name to the column
+	// definition. The column name used as a key is in lowercase.
+	columnByName map[string]*Column
+}
+
+// FilterableColumnByName returns the database name of the filterable column
+// with the given externally-visible name.
+func (t *Table) FilterableColumnByName(name string) (*Column, error) {
+	col := t.columnByName[strings.ToLower(name)]
+	if col != nil && col.filterable {
+		return col, nil
+	}
+
+	columnNames := []string{}
+	for _, column := range t.columns {
+		if column.filterable {
+			columnNames = append(columnNames, column.name)
+		}
+	}
+	return nil, fmt.Errorf("no filterable field named %q, valid fields are %s", name, strings.Join(columnNames, ", "))
+}
+
+// SortableColumnByName returns the sortable database column
+// with the given externally-visible name.
+func (t *Table) SortableColumnByName(name string) (*Column, error) {
+	col := t.columnByName[strings.ToLower(name)]
+	if col != nil && col.sortable {
+		return col, nil
+	}
+
+	columnNames := []string{}
+	for _, column := range t.columns {
+		if column.sortable {
+			columnNames = append(columnNames, column.name)
+		}
+	}
+	return nil, fmt.Errorf("no sortable field named %q, valid fields are %s", name, strings.Join(columnNames, ", "))
+}
diff --git a/analysis/internal/aip/datamodel_builder.go b/analysis/internal/aip/datamodel_builder.go
new file mode 100644
index 0000000..4f529ad
--- /dev/null
+++ b/analysis/internal/aip/datamodel_builder.go
@@ -0,0 +1,103 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import "strings"
+
+type ColumnBuilder struct {
+	column Column
+}
+
+// NewColumn starts building a new column.
+func NewColumn() *ColumnBuilder {
+	return &ColumnBuilder{}
+}
+
+// WithName specifies the user-visible name of the column.
+func (c *ColumnBuilder) WithName(name string) *ColumnBuilder {
+	c.column.name = name
+	return c
+}
+
+// WithDatabaseName specifies the database name of the column.
+// Important: Only pass safe values (e.g. compile-time constants) to this
+// field.
+// User input MUST NOT flow to this field, as it will be used directly
+// in SQL statements and would allow the user to perform SQL injection
+// attacks.
+func (c *ColumnBuilder) WithDatabaseName(name string) *ColumnBuilder {
+	c.column.databaseName = name
+	return c
+}
+
+// Sortable specifies this column can be sorted on.
+func (c *ColumnBuilder) Sortable() *ColumnBuilder {
+	c.column.sortable = true
+	return c
+}
+
+// Filterable specifies this column can be filtered on.
+func (c *ColumnBuilder) Filterable() *ColumnBuilder {
+	c.column.filterable = true
+	return c
+}
+
+// FilterableImplicitly specifies this column can be filtered on implicitly.
+// This means that AIP-160 filter expressions not referencing any
+// particular field will try to search in this column.
+func (c *ColumnBuilder) FilterableImplicitly() *ColumnBuilder {
+	c.column.filterable = true
+	c.column.implicitFilter = true
+	return c
+}
+
+// Build returns the built column.
+func (c *ColumnBuilder) Build() *Column {
+	result := &Column{}
+	*result = c.column
+	return result
+}
+
+type TableBuilder struct {
+	columns []*Column
+}
+
+// NewTable starts building a new table.
+func NewTable() *TableBuilder {
+	return &TableBuilder{}
+}
+
+// WithColumns specifies the columns in the table.
+func (t *TableBuilder) WithColumns(columns ...*Column) *TableBuilder {
+	t.columns = columns
+	return t
+}
+
+// Build returns the built table.
+func (t *TableBuilder) Build() *Table {
+	columnByName := make(map[string]*Column)
+	for _, c := range t.columns {
+		lowerName := strings.ToLower(c.name)
+		if _, ok := columnByName[lowerName]; ok {
+			panic("multiple columns with the same name: " + lowerName)
+		}
+		columnByName[strings.ToLower(c.name)] = c
+	}
+
+	return &Table{
+		columns:      t.columns,
+		columnByName: columnByName,
+	}
+}
diff --git a/analysis/internal/aip/filter_generator.go b/analysis/internal/aip/filter_generator.go
new file mode 100644
index 0000000..881302e
--- /dev/null
+++ b/analysis/internal/aip/filter_generator.go
@@ -0,0 +1,263 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+)
+
+// whereClause constructs Standard SQL WHERE clause parts from
+// column definitions and a parsed AIP-160 filter.
+type whereClause struct {
+	table         *Table
+	parameters    []QueryParameter
+	namePrefix    string
+	nextValueName int
+}
+
+// QueryParameter represents a query parameter.
+type QueryParameter struct {
+	Name  string
+	Value string
+}
+
+// WhereClause creates a Standard SQL WHERE clause fragment for the given filter.
+//
+// The fragment will be enclosed in parentheses and does not include the "WHERE" keyword.
+// For example: (column LIKE @param1)
+// Also returns the query parameters which need to be given to the database.
+//
+// All field names are replaced with the safe database column names from the specified table.
+// All user input strings are passed via query parameters, so the returned query is SQL injection safe.
+func (t *Table) WhereClause(filter *Filter, parameterPrefix string) (string, []QueryParameter, error) {
+	if filter.Expression == nil {
+		return "(TRUE)", []QueryParameter{}, nil
+	}
+
+	q := &whereClause{
+		table:      t,
+		namePrefix: parameterPrefix,
+	}
+
+	clause, err := q.expressionQuery(filter.Expression)
+	if err != nil {
+		return "", []QueryParameter{}, err
+	}
+	return clause, q.parameters, nil
+}
+
+// expressionQuery returns the SQL expression equivalent to the given
+// filter expression.
+// An expression is a conjunction (AND) of sequences or a simple
+// sequence.
+//
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) expressionQuery(expression *Expression) (string, error) {
+	factors := []string{}
+	// Both Sequence and Factor is equivalent to AND of the
+	// component Sequences and Factors (respectively), as we implement
+	// exact match semantics and do not support ranking
+	// based on the number of factors that match.
+	for _, sequence := range expression.Sequences {
+		for _, factor := range sequence.Factors {
+			f, err := w.factorQuery(factor)
+			if err != nil {
+				return "", err
+			}
+			factors = append(factors, f)
+		}
+	}
+	if len(factors) == 1 {
+		return factors[0], nil
+	}
+	return "(" + strings.Join(factors, " AND ") + ")", nil
+}
+
+// factorQuery returns the SQL expression equivalent to the given
+// factor. A factor is a disjunction (OR) of terms or a simple term.
+//
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) factorQuery(factor *Factor) (string, error) {
+	terms := []string{}
+	for _, term := range factor.Terms {
+		tq, err := w.termQuery(term)
+		if err != nil {
+			return "", err
+		}
+		terms = append(terms, tq)
+	}
+	if len(terms) == 1 {
+		return terms[0], nil
+	}
+	return "(" + strings.Join(terms, " OR ") + ")", nil
+}
+
+// termQuery returns the SQL expression equivalent to the given
+// term.
+//
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) termQuery(term *Term) (string, error) {
+	simpleQuery, err := w.simpleQuery(term.Simple)
+	if err != nil {
+		return "", err
+	}
+	if term.Negated {
+		return fmt.Sprintf("(NOT %s)", simpleQuery), nil
+	}
+	return simpleQuery, nil
+}
+
+// simpleQuery returns the SQL expression equivalent to the given simple
+// filter.
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) simpleQuery(simple *Simple) (string, error) {
+	if simple.Restriction != nil {
+		return w.restrictionQuery(simple.Restriction)
+	} else if simple.Composite != nil {
+		return w.expressionQuery(simple.Composite)
+	} else {
+		return "", fmt.Errorf("invalid 'simple' clause in query filter")
+	}
+}
+
+// restrictionQuery returns the SQL expression equivalent to the given
+// restriction.
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) restrictionQuery(restriction *Restriction) (string, error) {
+	if restriction.Comparable.Member == nil {
+		return "", fmt.Errorf("invalid comparable")
+	}
+	if len(restriction.Comparable.Member.Fields) > 0 {
+		return "", fmt.Errorf("fields not implemented yet")
+	}
+	if restriction.Comparator == "" {
+		arg, err := w.likeComparableValue(restriction.Comparable)
+		if err != nil {
+			return "", err
+		}
+		clauses := []string{}
+		// This is a value that should be substring matched against columns
+		// marked for implicit matching.
+		for _, column := range w.table.columns {
+			if column.implicitFilter {
+				clauses = append(clauses, fmt.Sprintf("%s LIKE %s", column.databaseName, arg))
+			}
+		}
+		return "(" + strings.Join(clauses, " OR ") + ")", nil
+	} else if restriction.Comparator == "=" {
+		arg, err := w.argValue(restriction.Arg)
+		if err != nil {
+			return "", err
+		}
+		column, err := w.table.FilterableColumnByName(restriction.Comparable.Member.Value)
+		if err != nil {
+			return "", err
+		}
+		return fmt.Sprintf("(%s = %s)", column.databaseName, arg), nil
+	} else if restriction.Comparator == "!=" {
+		arg, err := w.argValue(restriction.Arg)
+		if err != nil {
+			return "", err
+		}
+		column, err := w.table.FilterableColumnByName(restriction.Comparable.Member.Value)
+		if err != nil {
+			return "", err
+		}
+		return fmt.Sprintf("(%s <> %s)", column.databaseName, arg), nil
+	} else if restriction.Comparator == ":" {
+		arg, err := w.likeArgValue(restriction.Arg)
+		if err != nil {
+			return "", err
+		}
+		column, err := w.table.FilterableColumnByName(restriction.Comparable.Member.Value)
+		if err != nil {
+			return "", err
+		}
+		return fmt.Sprintf("(%s LIKE %s)", column.databaseName, arg), nil
+	} else {
+		return "", fmt.Errorf("comparator operator not implemented yet")
+	}
+}
+
+// argValue returns a SQL expression representing the value of the specified
+// arg.
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) argValue(arg *Arg) (string, error) {
+	if arg.Composite != nil {
+		return "", fmt.Errorf("composite expressions in arguments not implemented yet")
+	}
+	if arg.Comparable == nil {
+		return "", fmt.Errorf("missing comparable in argument")
+	}
+	return w.comparableValue(arg.Comparable)
+}
+
+// argValue returns a SQL expression representing the value of the specified
+// comparable.
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) comparableValue(comparable *Comparable) (string, error) {
+	if comparable.Member == nil {
+		return "", fmt.Errorf("invalid comparable")
+	}
+	if len(comparable.Member.Fields) > 0 {
+		return "", fmt.Errorf("fields not implemented yet")
+	}
+	// Bind unsanitised user input to a parameter to protect against SQL injection.
+	return w.bind(comparable.Member.Value), nil
+
+}
+
+// likeArgValue returns a SQL expression that, when passed to the
+// right hand side of a LIKE operator, performs substring matching against
+// the value of the argument.
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) likeArgValue(arg *Arg) (string, error) {
+	if arg.Composite != nil {
+		return "", fmt.Errorf("composite expressions are not allowed as RHS to has (:) operator")
+	}
+	if arg.Comparable == nil {
+		return "", fmt.Errorf("missing comparable in argument")
+	}
+	return w.likeComparableValue(arg.Comparable)
+}
+
+// likeComparableValue returns a SQL expression that, when passed to the
+// right hand side of a LIKE operator, performs substring matching against
+// the value of the comparable.
+// The returned string is an injection-safe SQL expression.
+func (w *whereClause) likeComparableValue(comparable *Comparable) (string, error) {
+	if comparable.Member == nil {
+		return "", fmt.Errorf("invalid comparable")
+	}
+	if len(comparable.Member.Fields) > 0 {
+		return "", fmt.Errorf("fields are not allowed on the RHS of has (:) operator")
+	}
+	// Bind unsanitised user input to a parameter to protect against SQL injection.
+	return w.bind("%" + spanutil.QuoteLike(comparable.Member.Value) + "%"), nil
+}
+
+// bind binds a new query parameter with the given value, and returns
+// the name of the parameter (including '@').
+// The returned string is an injection-safe SQL expression.
+func (q *whereClause) bind(value string) string {
+	name := q.namePrefix + strconv.Itoa(q.nextValueName)
+	q.nextValueName += 1
+	q.parameters = append(q.parameters, QueryParameter{Name: name, Value: value})
+	return "@" + name
+}
diff --git a/analysis/internal/aip/filter_generator_test.go b/analysis/internal/aip/filter_generator_test.go
new file mode 100644
index 0000000..3d244f1
--- /dev/null
+++ b/analysis/internal/aip/filter_generator_test.go
@@ -0,0 +1,163 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestWhereClause(t *testing.T) {
+	Convey("WhereClause", t, func() {
+		table := NewTable().WithColumns(
+			NewColumn().WithName("foo").WithDatabaseName("db_foo").FilterableImplicitly().Build(),
+			NewColumn().WithName("bar").WithDatabaseName("db_bar").FilterableImplicitly().Build(),
+			NewColumn().WithName("baz").WithDatabaseName("db_baz").Filterable().Build(),
+			NewColumn().WithName("unfilterable").WithDatabaseName("unfilterable").Build(),
+		).Build()
+
+		Convey("Empty filter", func() {
+			result, pars, err := table.WhereClause(&Filter{}, "p_")
+			So(err, ShouldBeNil)
+			So(pars, ShouldHaveLength, 0)
+			So(result, ShouldEqual, "(TRUE)")
+		})
+		Convey("Simple filter", func() {
+			Convey("has operator", func() {
+				filter, err := ParseFilter("foo:somevalue")
+				So(err, ShouldEqual, nil)
+
+				result, pars, err := table.WhereClause(filter, "p_")
+				So(err, ShouldBeNil)
+				So(pars, ShouldResemble, []QueryParameter{
+					{
+						Name:  "p_0",
+						Value: "%somevalue%",
+					},
+				})
+				So(result, ShouldEqual, "(db_foo LIKE @p_0)")
+			})
+			Convey("equals operator", func() {
+				filter, err := ParseFilter("foo = somevalue")
+				So(err, ShouldEqual, nil)
+
+				result, pars, err := table.WhereClause(filter, "p_")
+				So(err, ShouldBeNil)
+				So(pars, ShouldResemble, []QueryParameter{
+					{
+						Name:  "p_0",
+						Value: "somevalue",
+					},
+				})
+				So(result, ShouldEqual, "(db_foo = @p_0)")
+			})
+			Convey("not equals operator", func() {
+				filter, err := ParseFilter("foo != somevalue")
+				So(err, ShouldEqual, nil)
+
+				result, pars, err := table.WhereClause(filter, "p_")
+				So(err, ShouldBeNil)
+				So(pars, ShouldResemble, []QueryParameter{
+					{
+						Name:  "p_0",
+						Value: "somevalue",
+					},
+				})
+				So(result, ShouldEqual, "(db_foo <> @p_0)")
+			})
+			Convey("implicit match operator", func() {
+				filter, err := ParseFilter("somevalue")
+				So(err, ShouldEqual, nil)
+
+				result, pars, err := table.WhereClause(filter, "p_")
+				So(err, ShouldBeNil)
+				So(pars, ShouldResemble, []QueryParameter{
+					{
+						Name:  "p_0",
+						Value: "%somevalue%",
+					},
+				})
+				So(result, ShouldEqual, "(db_foo LIKE @p_0 OR db_bar LIKE @p_0)")
+			})
+			Convey("unsupported composite to LIKE", func() {
+				filter, err := ParseFilter("foo:(somevalue)")
+				So(err, ShouldEqual, nil)
+
+				_, _, err = table.WhereClause(filter, "p_")
+				So(err, ShouldErrLike, "composite expressions are not allowed as RHS to has (:) operator")
+			})
+			Convey("unsupported composite to equals", func() {
+				filter, err := ParseFilter("foo=(somevalue)")
+				So(err, ShouldEqual, nil)
+
+				_, _, err = table.WhereClause(filter, "p_")
+				So(err, ShouldErrLike, "composite expressions in arguments not implemented yet")
+			})
+			Convey("unsupported field LHS", func() {
+				filter, err := ParseFilter("foo.baz=blah")
+				So(err, ShouldEqual, nil)
+
+				_, _, err = table.WhereClause(filter, "p_")
+				So(err, ShouldErrLike, "fields not implemented yet")
+			})
+			Convey("unsupported field RHS", func() {
+				filter, err := ParseFilter("foo=blah.baz")
+				So(err, ShouldEqual, nil)
+
+				_, _, err = table.WhereClause(filter, "p_")
+				So(err, ShouldErrLike, "fields not implemented yet")
+			})
+			Convey("field on RHS of has", func() {
+				filter, err := ParseFilter("foo:blah.baz")
+				So(err, ShouldEqual, nil)
+
+				_, _, err = table.WhereClause(filter, "p_")
+				So(err, ShouldErrLike, "fields are not allowed on the RHS of has (:) operator")
+			})
+		})
+		Convey("Complex filter", func() {
+			filter, err := ParseFilter("implicit (foo=explicitone) OR -bar=explicittwo AND foo!=explicitthree OR baz:explicitfour")
+			So(err, ShouldEqual, nil)
+
+			result, pars, err := table.WhereClause(filter, "p_")
+			So(err, ShouldBeNil)
+			So(pars, ShouldResemble, []QueryParameter{
+				{
+					Name:  "p_0",
+					Value: "%implicit%",
+				},
+				{
+					Name:  "p_1",
+					Value: "explicitone",
+				},
+				{
+					Name:  "p_2",
+					Value: "explicittwo",
+				},
+				{
+					Name:  "p_3",
+					Value: "explicitthree",
+				},
+				{
+					Name:  "p_4",
+					Value: "%explicitfour%",
+				},
+			})
+			So(result, ShouldEqual, "((db_foo LIKE @p_0 OR db_bar LIKE @p_0) AND ((db_foo = @p_1) OR (NOT (db_bar = @p_2))) AND ((db_foo <> @p_3) OR (db_baz LIKE @p_4)))")
+		})
+	})
+}
diff --git a/analysis/internal/aip/filter_parser.go b/analysis/internal/aip/filter_parser.go
new file mode 100644
index 0000000..c2880c0
--- /dev/null
+++ b/analysis/internal/aip/filter_parser.go
@@ -0,0 +1,698 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package aip contains utilities used to comply with API Improvement
+// Proposals (AIPs) from https://google.aip.dev/. This includes
+// an AIP-160 filter parser and AIP-132 order by clause parser.
+package aip
+
+// This file contains a lexer and parser for AIP-160 filter expressions.
+// The EBNF is at https://google.aip.dev/assets/misc/ebnf-filtering.txt
+// The function call syntax is not supported which simplifies the parser.
+//
+// Implemented EBNF (in terms of lexer tokens):
+// filter: [expression];
+// expression: sequence {WS AND WS sequence};
+// sequence: factor {WS factor};
+// factor: term {WS OR WS term};
+// term: [NEGATE] simple;
+// simple: restriction | composite;
+// restriction: comparable [COMPARATOR arg];
+// comparable: member;
+// member: (TEXT | STRING) {DOT TEXT};
+// composite: LPAREN expression RPAREN;
+// arg: comparable | composite;
+//
+// TODO(mwarton): Redo whitespace handling.  There are still some cases (like "- 30")
+// 				  which are accepted as valid instead of being rejected.
+import (
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+)
+
+const (
+	kindComparator = "COMPARATOR"
+	kindNegate     = "NEGATE"
+	kindAnd        = "AND"
+	kindOr         = "OR"
+	kindDot        = "DOT"
+	kindLParen     = "LPAREN"
+	kindRParen     = "RPAREN"
+	kindComma      = "COMMA"
+	kindString     = "STRING"
+	kindText       = "TEXT"
+	kindEnd        = "END"
+)
+
+// lexerRegexp has one group for each kind of token that can be lexed, in the order of the kind consts above. There are two cases for kindNegate to handle whitespace correctly.
+var lexerRegexp = regexp.MustCompile(`^(<=|>=|!=|<|>|=|\:)|(NOT\s)|(-)|(AND\s)|(OR\s)|(\.)|(\()|(\))|(,)|("(?:[^"\\]|\\.)*")|([^\s\.,<>=!:\(\)]+)`)
+
+type token struct {
+	kind  string
+	value string
+}
+
+type lexer struct {
+	input string
+	next  *token
+}
+
+func NewLexer(input string) *lexer {
+	return &lexer{input: input}
+}
+
+func (l *lexer) Peek() (*token, error) {
+	if l.next == nil {
+		var err error
+		l.next, err = l.Next()
+		if err != nil {
+			return nil, err
+		}
+	}
+	return l.next, nil
+}
+
+func (l *lexer) Next() (*token, error) {
+	if l.next != nil {
+		next := l.next
+		l.next = nil
+		return next, nil
+	}
+	l.next = nil
+	l.input = strings.TrimLeft(l.input, " \t\r\n")
+	if l.input == "" {
+		return &token{kind: kindEnd}, nil
+	}
+	matches := lexerRegexp.FindStringSubmatch(l.input)
+	if matches == nil {
+		return nil, fmt.Errorf("error: unable to lex token from %q", l.input)
+	}
+	l.input = l.input[len(matches[0]):]
+	if matches[1] != "" {
+		return &token{kind: kindComparator, value: matches[1]}, nil
+	}
+	if matches[2] != "" {
+		// Needs to be fixed up to compensate for the trailing \s in the match which prevents
+		// matching "NOTother" as a negated "other".
+		length := len(matches[2])
+		return &token{kind: kindNegate, value: matches[2][:length-1]}, nil
+	}
+	if matches[3] != "" {
+		return &token{kind: kindNegate, value: matches[3]}, nil
+	}
+	if matches[4] != "" {
+		// Needs to be fixed up to compensate for the trailing \s in the match which prevents
+		// matching "ANDother" as a "AND" "other".
+		length := len(matches[4])
+		return &token{kind: kindAnd, value: matches[4][:length-1]}, nil
+	}
+	if matches[5] != "" {
+		// Needs to be fixed up to compensate for the trailing \s in the match which prevents
+		// matching "ORother" as a "OR" "other".
+		length := len(matches[5])
+		return &token{kind: kindOr, value: matches[5][:length-1]}, nil
+	}
+	if matches[6] != "" {
+		return &token{kind: kindDot, value: matches[6]}, nil
+	}
+	if matches[7] != "" {
+		return &token{kind: kindLParen, value: matches[7]}, nil
+	}
+	if matches[8] != "" {
+		return &token{kind: kindRParen, value: matches[8]}, nil
+	}
+	if matches[9] != "" {
+		return &token{kind: kindComma, value: matches[9]}, nil
+	}
+	if matches[10] != "" {
+		return &token{kind: kindString, value: matches[10]}, nil
+	}
+	if matches[11] != "" {
+		return &token{kind: kindText, value: matches[11]}, nil
+	}
+	return nil, fmt.Errorf("error: unhandled lexer regexp match %q", matches[0])
+}
+
+// AST Nodes.  These are based on the EBNF at https://google.aip.dev/assets/misc/ebnf-filtering.txt
+// Note that the syntax for functions is not currently supported.
+
+// Filter, possibly empty
+type Filter struct {
+	Expression *Expression // Optional, may be nil.
+}
+
+func (v *Filter) String() string {
+	var s strings.Builder
+	s.WriteString("filter{")
+	if v.Expression != nil {
+		s.WriteString(v.Expression.String())
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Expressions may either be a conjunction (AND) of sequences or a simple
+// sequence.
+//
+// Note, the AND is case-sensitive.
+//
+// Example: `a b AND c AND d`
+//
+// The expression `(a b) AND c AND d` is equivalent to the example.
+type Expression struct {
+	// Sequences are always joined by an AND operator
+	Sequences []*Sequence
+}
+
+func (v *Expression) String() string {
+	var s strings.Builder
+	s.WriteString("expression{")
+	for i, c := range v.Sequences {
+		if i > 0 {
+			s.WriteString(",")
+		}
+		if c != nil {
+			s.WriteString(c.String())
+		}
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Sequence is composed of one or more whitespace (WS) separated factors.
+//
+// A sequence expresses a logical relationship between 'factors' where
+// the ranking of a filter result may be scored according to the number
+// factors that match and other such criteria as the proximity of factors
+// to each other within a document.
+//
+// When filters are used with exact match semantics rather than fuzzy
+// match semantics, a sequence is equivalent to AND.
+//
+// Example: `New York Giants OR Yankees`
+//
+// The expression `New York (Giants OR Yankees)` is equivalent to the
+// example.
+type Sequence struct {
+	// Factors are always joined by an (implicit) AND operator
+	Factors []*Factor
+}
+
+func (v *Sequence) String() string {
+	var s strings.Builder
+	s.WriteString("sequence{")
+	for i, c := range v.Factors {
+		if i > 0 {
+			s.WriteString(",")
+		}
+		if c != nil {
+			s.WriteString(c.String())
+		}
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Factors may either be a disjunction (OR) of terms or a simple term.
+//
+// Note, the OR is case-sensitive.
+//
+// Example: `a < 10 OR a >= 100`
+type Factor struct {
+	// Terms are always joined by an OR operator
+	Terms []*Term
+}
+
+func (v *Factor) String() string {
+	var s strings.Builder
+	s.WriteString("factor{")
+	for i, c := range v.Terms {
+		if i > 0 {
+			s.WriteString(",")
+		}
+		if c != nil {
+			s.WriteString(c.String())
+		}
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Terms may either be unary or simple expressions.
+//
+// Unary expressions negate the simple expression, either mathematically `-`
+// or logically `NOT`. The negation styles may be used interchangeably.
+//
+// Note, the `NOT` is case-sensitive and must be followed by at least one
+// whitespace (WS).
+//
+// Examples:
+// * logical not     : `NOT (a OR b)`
+// * alternative not : `-file:".java"`
+// * negation        : `-30`
+type Term struct {
+	Negated bool
+	Simple  *Simple
+}
+
+func (v *Term) String() string {
+	var s strings.Builder
+	s.WriteString(fmt.Sprintf("term{"))
+	if v.Negated {
+		s.WriteString("-")
+	}
+	if v.Simple != nil {
+		s.WriteString(v.Simple.String())
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Simple expressions may either be a restriction or a nested (composite)
+// expression.
+type Simple struct {
+	Restriction *Restriction
+	// Composite is a parenthesized expression, commonly used to group
+	// terms or clarify operator precedence.
+	//
+	// Example: `(msg.endsWith('world') AND retries < 10)`
+	Composite *Expression
+}
+
+func (v *Simple) String() string {
+	var s strings.Builder
+	s.WriteString("simple{")
+	if v.Restriction != nil {
+		s.WriteString(v.Restriction.String())
+	}
+	if v.Restriction != nil && v.Composite != nil {
+		s.WriteString(",")
+	}
+	if v.Composite != nil {
+		s.WriteString(v.Composite.String())
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Restrictions express a relationship between a comparable value and a
+// single argument. When the restriction only specifies a comparable
+// without an operator, this is a global restriction.
+//
+// Note, restrictions are not whitespace sensitive.
+//
+// Examples:
+// * equality         : `package=com.google`
+// * inequality       : `msg != 'hello'`
+// * greater than     : `1 > 0`
+// * greater or equal : `2.5 >= 2.4`
+// * less than        : `yesterday < request.time`
+// * less or equal    : `experiment.rollout <= cohort(request.user)`
+// * has              : `map:key`
+// * global           : `prod`
+//
+// In addition to the global, equality, and ordering operators, filters
+// also support the has (`:`) operator. The has operator is unique in
+// that it can test for presence or value based on the proto3 type of
+// the `comparable` value. The has operator is useful for validating the
+// structure and contents of complex values.
+type Restriction struct {
+	Comparable *Comparable
+	// Comparators supported by list filters: <=, <. >=, >, !=, =, :
+	Comparator string
+	Arg        *Arg
+}
+
+func (v *Restriction) String() string {
+	var s strings.Builder
+	s.WriteString("restriction{")
+	if v.Comparable != nil {
+		s.WriteString(v.Comparable.String())
+	}
+	if v.Comparator != "" {
+		s.WriteString(",")
+		s.WriteString(strconv.Quote(v.Comparator))
+	}
+	if v.Arg != nil {
+		s.WriteString(",")
+		s.WriteString(v.Arg.String())
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+type Arg struct {
+	Comparable *Comparable
+	// Composite is a parenthesized expression, commonly used to group
+	// terms or clarify operator precedence.
+	//
+	// Example: `(msg.endsWith('world') AND retries < 10)`
+	Composite *Expression
+}
+
+func (v *Arg) String() string {
+	var s strings.Builder
+	s.WriteString("arg{")
+	if v.Comparable != nil {
+		s.WriteString(v.Comparable.String())
+	}
+	if v.Comparable != nil && v.Composite != nil {
+		s.WriteString(",")
+	}
+	if v.Composite != nil {
+		s.WriteString(v.Composite.String())
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Comparable may either be a member or function.  As functions are not currently supported, it is always a member.
+type Comparable struct {
+	Member *Member
+}
+
+func (v *Comparable) String() string {
+	var s strings.Builder
+	s.WriteString("comparable{")
+	if v.Member != nil {
+		s.WriteString(v.Member.String())
+	}
+	s.WriteString("}")
+	return s.String()
+}
+
+// Member expressions are either value or DOT qualified field references.
+//
+// Example: `expr.type_map.1.type`
+type Member struct {
+	Value  string
+	Fields []string
+}
+
+func (v *Member) String() string {
+	var s strings.Builder
+	s.WriteString("member{")
+	s.Write([]byte(strconv.Quote(v.Value)))
+	if len(v.Fields) > 0 {
+		s.WriteString(", {")
+	}
+	for i, c := range v.Fields {
+		if i > 0 {
+			s.WriteString(",")
+		}
+		s.WriteString(strconv.Quote(c))
+	}
+	s.WriteString("}}")
+	return s.String()
+}
+
+// Parse an AIP-160 filter string into an AST.
+func ParseFilter(filter string) (*Filter, error) {
+	return newParser(filter).filter()
+}
+
+type parser struct {
+	lexer lexer
+}
+
+func newParser(input string) *parser {
+	return &parser{lexer: *NewLexer(input)}
+}
+
+func (p *parser) expect(kind string) error {
+	t, err := p.lexer.Peek()
+	if err != nil {
+		return err
+	}
+	if t.kind != kind {
+		return fmt.Errorf("expected %s but got %s(%q)", kind, t.kind, t.value)
+	}
+	_, err = p.lexer.Next()
+	return err
+}
+
+func (p *parser) accept(kind string) (*token, error) {
+	t, err := p.lexer.Peek()
+	if err != nil {
+		return nil, err
+	}
+	if t.kind != kind {
+		return nil, nil
+	}
+	return p.lexer.Next()
+}
+
+func (p *parser) filter() (*Filter, error) {
+	t, err := p.accept(kindEnd)
+	if err != nil {
+		return nil, err
+	}
+	if t != nil {
+		return &Filter{}, nil
+	}
+	e, err := p.expression()
+	if err != nil {
+		return nil, err
+	}
+	return &Filter{Expression: e}, p.expect(kindEnd)
+}
+
+func (p *parser) expression() (*Expression, error) {
+	s, err := p.sequence()
+	if err != nil {
+		return nil, err
+	}
+	if s == nil {
+		return nil, nil
+	}
+	e := &Expression{}
+	e.Sequences = append(e.Sequences, s)
+	for {
+		and, err := p.accept(kindAnd)
+		if err != nil {
+			return nil, err
+		}
+		if and == nil {
+			break
+		}
+		s, err := p.sequence()
+		if err != nil {
+			return nil, err
+		}
+		if s == nil {
+			return nil, fmt.Errorf("expected sequence after AND")
+		}
+		e.Sequences = append(e.Sequences, s)
+	}
+	return e, nil
+}
+
+func (p *parser) sequence() (*Sequence, error) {
+	s := &Sequence{}
+	for {
+		f, err := p.factor()
+		if err != nil {
+			return nil, err
+		}
+		if f == nil {
+			break
+		}
+		s.Factors = append(s.Factors, f)
+	}
+	if len(s.Factors) == 0 {
+		return nil, nil
+	}
+	return s, nil
+}
+
+func (p *parser) factor() (*Factor, error) {
+	t, err := p.term()
+	if err != nil {
+		return nil, err
+	}
+	if t == nil {
+		return nil, nil
+	}
+	f := &Factor{}
+	f.Terms = append(f.Terms, t)
+	for {
+		or, err := p.accept(kindOr)
+		if err != nil {
+			return nil, err
+		}
+		if or == nil {
+			break
+		}
+		t, err := p.term()
+		if err != nil {
+			return nil, err
+		}
+		if t == nil {
+			return nil, fmt.Errorf("expected sequence after AND")
+		}
+		f.Terms = append(f.Terms, t)
+	}
+	return f, nil
+}
+
+func (p *parser) term() (*Term, error) {
+	n, err := p.accept(kindNegate)
+	if err != nil {
+		return nil, err
+	}
+	s, err := p.simple()
+	if err != nil {
+		return nil, err
+	}
+	if s == nil {
+		if n != nil {
+			return nil, fmt.Errorf("expected simple term after negation %q", n.value)
+		}
+		return nil, nil
+	}
+	return &Term{Negated: n != nil, Simple: s}, nil
+}
+
+func (p *parser) simple() (*Simple, error) {
+	r, err := p.restriction()
+	if err != nil {
+		return nil, err
+	}
+	if r != nil {
+		return &Simple{Restriction: r}, nil
+	}
+	c, err := p.composite()
+	if err != nil {
+		return nil, err
+	}
+	if c != nil {
+		return &Simple{Composite: c}, nil
+	}
+	return nil, nil
+}
+
+func (p *parser) restriction() (*Restriction, error) {
+	comparable, err := p.comparable()
+	if err != nil {
+		return nil, err
+	}
+	if comparable == nil {
+		return nil, nil
+	}
+	comparator, err := p.accept(kindComparator)
+	if err != nil {
+		return nil, err
+	}
+	if comparator == nil {
+		return &Restriction{Comparable: comparable}, nil
+	}
+	arg, err := p.arg()
+	if err != nil {
+		return nil, err
+	}
+	if arg == nil {
+		return nil, fmt.Errorf("expected arg after %s", comparator.value)
+	}
+	return &Restriction{Comparable: comparable, Comparator: comparator.value, Arg: arg}, nil
+}
+
+func (p *parser) comparable() (*Comparable, error) {
+	m, err := p.member()
+	if err != nil {
+		return nil, err
+	}
+	if m == nil {
+		return nil, nil
+	}
+	return &Comparable{Member: m}, nil
+}
+
+func (p *parser) member() (*Member, error) {
+	v, err := p.accept(kindString)
+	if err != nil {
+		return nil, err
+	}
+	if v != nil {
+		v.value, err = strconv.Unquote(v.value)
+		if err != nil {
+			return nil, fmt.Errorf("error unquoting string: %w", err)
+		}
+		return &Member{Value: v.value}, nil
+	}
+
+	v, err = p.accept(kindText)
+	if err != nil {
+		return nil, err
+	}
+	if v == nil {
+		return nil, nil
+	}
+	m := &Member{Value: v.value}
+	for {
+		dot, err := p.accept(kindDot)
+		if err != nil {
+			return nil, err
+		}
+		if dot == nil {
+			break
+		}
+		f, err := p.accept(kindText)
+		if err != nil {
+			return nil, err
+		}
+		if f == nil {
+			return nil, fmt.Errorf("expected field name after '.'")
+		}
+		m.Fields = append(m.Fields, f.value)
+	}
+	return m, nil
+}
+
+func (p *parser) composite() (*Expression, error) {
+	lparen, err := p.accept(kindLParen)
+	if err != nil {
+		return nil, err
+	}
+	if lparen == nil {
+		return nil, nil
+	}
+	e, err := p.expression()
+	if err != nil {
+		return nil, err
+	}
+	if e == nil {
+		return nil, fmt.Errorf("expected expression")
+	}
+	return e, p.expect(kindRParen)
+}
+
+func (p *parser) arg() (*Arg, error) {
+	comparable, err := p.comparable()
+	if err != nil {
+		return nil, err
+	}
+	if comparable != nil {
+		return &Arg{Comparable: comparable}, nil
+	}
+	composite, err := p.composite()
+	if err != nil {
+		return nil, err
+	}
+	if composite != nil {
+		return &Arg{Composite: composite}, nil
+	}
+	return nil, nil
+}
diff --git a/analysis/internal/aip/filter_parser_test.go b/analysis/internal/aip/filter_parser_test.go
new file mode 100644
index 0000000..4a41046
--- /dev/null
+++ b/analysis/internal/aip/filter_parser_test.go
@@ -0,0 +1,178 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import "testing"
+
+func TestTokenKinds(t *testing.T) {
+	tests := []struct {
+		input string
+		kind  string
+		value string
+	}{
+		{input: "<= 10", kind: kindComparator, value: "<="},
+		{input: "-file", kind: kindNegate, value: "-"},
+		{input: "NOT file", kind: kindNegate, value: "NOT"},
+		{input: "AND b", kind: kindAnd, value: "AND"},
+		{input: "OR a", kind: kindOr, value: "OR"},
+		{input: ".field", kind: kindDot, value: "."},
+		{input: "(arg)", kind: kindLParen, value: "("},
+		{input: ")", kind: kindRParen, value: ")"},
+		{input: ", arg2)", kind: kindComma, value: ","},
+		{input: "text", kind: kindText, value: "text"},
+		{input: "\"string\"", kind: kindString, value: "\"string\""},
+	}
+	for _, test := range tests {
+		t.Run(test.value, func(t *testing.T) {
+			token, err := NewLexer(test.input).Next()
+			if err != nil {
+				t.Fatalf("Error when lexing: %v", err)
+			}
+			if token.kind != test.kind {
+				t.Errorf("Wrong kind: got %s, want %s", token.kind, test.kind)
+			}
+			if token.value != test.value {
+				t.Errorf("Wrong kind: got %s, want %s", token.value, test.value)
+			}
+		})
+	}
+}
+
+func TestWhitespaceLexing(t *testing.T) {
+	filter := "text \"string with whitespace\" (43 AND 44) OR 45 NOT function(arg1, arg2):hello -field1.field2: hello field < 36"
+	tokens := []token{
+		{kind: kindText, value: "text"},
+		{kind: kindString, value: "\"string with whitespace\""},
+		{kind: kindLParen, value: "("},
+		{kind: kindText, value: "43"},
+		{kind: kindAnd, value: "AND"},
+		{kind: kindText, value: "44"},
+		{kind: kindRParen, value: ")"},
+		{kind: kindOr, value: "OR"},
+		{kind: kindText, value: "45"},
+		{kind: kindNegate, value: "NOT"},
+		{kind: kindText, value: "function"},
+		{kind: kindLParen, value: "("},
+		{kind: kindText, value: "arg1"},
+		{kind: kindComma, value: ","},
+		{kind: kindText, value: "arg2"},
+		{kind: kindRParen, value: ")"},
+		{kind: kindComparator, value: ":"},
+		{kind: kindText, value: "hello"},
+		{kind: kindNegate, value: "-"},
+		{kind: kindText, value: "field1"},
+		{kind: kindDot, value: "."},
+		{kind: kindText, value: "field2"},
+		{kind: kindComparator, value: ":"},
+		{kind: kindText, value: "hello"},
+		{kind: kindText, value: "field"},
+		{kind: kindComparator, value: "<"},
+		{kind: kindText, value: "36"},
+		{kind: kindEnd, value: ""},
+		{kind: kindEnd, value: ""},
+	}
+	l := NewLexer(filter)
+	for i, expected := range tokens {
+		actual, err := l.Next()
+		if err != nil {
+			t.Fatalf("Error getting next token: %v", err)
+		}
+		if actual.kind != expected.kind {
+			t.Errorf("wrong token kind for token %d: got %s, want %s", i, actual.kind, expected.kind)
+		}
+		if actual.value != expected.value {
+			t.Errorf("wrong token value for token %d: got %s, want %s", i, actual.value, expected.value)
+		}
+	}
+}
+
+func TestFullParse(t *testing.T) {
+	tests := []struct {
+		input     string
+		ast       string
+		expectErr bool
+	}{
+		{input: "", ast: "filter{}"},
+		{input: " ", ast: "filter{}"},
+		{input: "simple", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"simple\"}}}}}}}}}}"},
+		{input: " wsBefore", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"wsBefore\"}}}}}}}}}}"},
+		{input: "wsAfter ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"wsAfter\"}}}}}}}}}}"},
+		{input: " wsAround ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"wsAround\"}}}}}}}}}}"},
+		{input: "\"string\"", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"string\"}}}}}}}}}}"},
+		{input: " \"string\" ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"string\"}}}}}}}}}}"},
+		{input: "\"ws string\"", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"ws string\"}}}}}}}}}}"},
+		{input: "-negated", ast: "filter{expression{sequence{factor{term{-simple{restriction{comparable{member{\"negated\"}}}}}}}}}}"},
+		{input: " - negated ", ast: "filter{expression{sequence{factor{term{-simple{restriction{comparable{member{\"negated\"}}}}}}}}}}"},
+		// This is a common case (lots of test names are separated by -).
+		{input: "dash-separated-name", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"dash-separated-name\"}}}}}}}}}}"},
+		{input: "term -negated-term", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"term\"}}}}}}},factor{term{-simple{restriction{comparable{member{\"negated-term\"}}}}}}}}}}"},
+		{input: "NOT negated", ast: "filter{expression{sequence{factor{term{-simple{restriction{comparable{member{\"negated\"}}}}}}}}}}"},
+		{input: " NOT negated ", ast: "filter{expression{sequence{factor{term{-simple{restriction{comparable{member{\"negated\"}}}}}}}}}}"},
+		{input: " NOTnegated ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"NOTnegated\"}}}}}}}}}}"},
+		{input: "implicit and", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"implicit\"}}}}}}},factor{term{simple{restriction{comparable{member{\"and\"}}}}}}}}}}"},
+		{input: " implicit and ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"implicit\"}}}}}}},factor{term{simple{restriction{comparable{member{\"and\"}}}}}}}}}}"},
+		{input: "explicit AND and", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"explicit\"}}}}}}}},sequence{factor{term{simple{restriction{comparable{member{\"and\"}}}}}}}}}}"},
+		{input: "explicit AND ", expectErr: true},
+		{input: "explicit AND and", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"explicit\"}}}}}}}},sequence{factor{term{simple{restriction{comparable{member{\"and\"}}}}}}}}}}"},
+		{input: " explicit AND and ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"explicit\"}}}}}}}},sequence{factor{term{simple{restriction{comparable{member{\"and\"}}}}}}}}}}"},
+		{input: " explicit ANDnotand ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"explicit\"}}}}}}},factor{term{simple{restriction{comparable{member{\"ANDnotand\"}}}}}}}}}}"},
+		{input: "test OR or", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"test\"}}}}}},term{simple{restriction{comparable{member{\"or\"}}}}}}}}}}"},
+		{input: "test OR ", expectErr: true},
+		{input: "test ORnotor", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"test\"}}}}}}},factor{term{simple{restriction{comparable{member{\"ORnotor\"}}}}}}}}}}"},
+		{input: " test OR or ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"test\"}}}}}},term{simple{restriction{comparable{member{\"or\"}}}}}}}}}}"},
+		{input: " testORor ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"testORor\"}}}}}}}}}}"},
+		{input: "implicit and AND explicit", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"implicit\"}}}}}}},factor{term{simple{restriction{comparable{member{\"and\"}}}}}}}},sequence{factor{term{simple{restriction{comparable{member{\"explicit\"}}}}}}}}}}"},
+		{input: "implicit with OR term AND explicit OR term", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"implicit\"}}}}}}},factor{term{simple{restriction{comparable{member{\"with\"}}}}}},term{simple{restriction{comparable{member{\"term\"}}}}}}}},sequence{factor{term{simple{restriction{comparable{member{\"explicit\"}}}}}},term{simple{restriction{comparable{member{\"term\"}}}}}}}}}}"},
+		{input: "(composite)", ast: "filter{expression{sequence{factor{term{simple{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}}}}}}}}}}"},
+		{input: " (composite) ", ast: "filter{expression{sequence{factor{term{simple{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}}}}}}}}}}"},
+		{input: "( composite )", ast: "filter{expression{sequence{factor{term{simple{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}}}}}}}}}}"},
+		{input: " ( composite ) ", ast: "filter{expression{sequence{factor{term{simple{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}}}}}}}}}}"},
+		{input: " ( composite multi) ", ast: "filter{expression{sequence{factor{term{simple{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}},factor{term{simple{restriction{comparable{member{\"multi\"}}}}}}}}}}}}}}}"},
+		{input: "value<21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"<\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value < 21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"<\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: " value < 21 ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"<\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value<=21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"<=\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value>21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\">\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value>=21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\">=\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value=21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"=\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value!=21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"!=\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value:21", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\":\",arg{comparable{member{\"21\"}}}}}}}}}}}"},
+		{input: "value=(composite)", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"value\"}}},\"=\",arg{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}}}}}}}}}}}}"},
+		// Note: although this parses correctly as a "global" restriction, the implementation doesn't handle this type of restriction, so an error will be returned higher in the stack.
+		{input: "member.field", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"member\", {\"field\"}}}}}}}}}}"},
+		{input: " member.field > 4 ", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"member\", {\"field\"}}},\">\",arg{comparable{member{\"4\"}}}}}}}}}}}"},
+		{input: "composite (expression)", ast: "filter{expression{sequence{factor{term{simple{restriction{comparable{member{\"composite\"}}}}}}},factor{term{simple{expression{sequence{factor{term{simple{restriction{comparable{member{\"expression\"}}}}}}}}}}}}}}}"},
+		// This should parse as a function, but function parsing is not implemented.
+		// {input: "function(expression)", ast: ""},
+	}
+	for _, test := range tests {
+		t.Run(test.input, func(t *testing.T) {
+			filter, err := ParseFilter(test.input)
+			if test.expectErr {
+				if err == nil {
+					t.Fatalf("expected error but no error produced from input: %q\nparsed as:%q", test.input, filter.String())
+				}
+				return
+			}
+			if err != nil {
+				t.Fatal(err)
+			}
+			ast := filter.String()
+			if ast != test.ast {
+				t.Errorf("incorrect AST parsed from input %q:\ngot %q\nwant %q", test.input, ast, test.ast)
+			}
+		})
+	}
+}
diff --git a/analysis/internal/aip/orderby_generator.go b/analysis/internal/aip/orderby_generator.go
new file mode 100644
index 0000000..b4ff4e1
--- /dev/null
+++ b/analysis/internal/aip/orderby_generator.go
@@ -0,0 +1,74 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import (
+	"fmt"
+	"strings"
+)
+
+// MergeWithDefaultOrder merges the specified order with the given
+// defaultOrder. The merge occurs as follows:
+// - Ordering specified in `order` takes precedence.
+// - For columns not specified in the `order` that appear in `defaultOrder`,
+//   ordering is applied in the order they apply in defaultOrder.
+func MergeWithDefaultOrder(defaultOrder []OrderBy, order []OrderBy) []OrderBy {
+	result := make([]OrderBy, 0, len(order)+len(defaultOrder))
+	seenColumns := make(map[string]struct{})
+	for _, o := range order {
+		result = append(result, o)
+		seenColumns[strings.ToLower(o.Name)] = struct{}{}
+	}
+	for _, o := range defaultOrder {
+		if _, ok := seenColumns[strings.ToLower(o.Name)]; !ok {
+			result = append(result, o)
+		}
+	}
+	return result
+}
+
+// OrderByClause returns a Standard SQL Order by clause, including
+// "ORDER BY" and trailing new line (if an order is specified).
+// If no order is specified, returns "".
+//
+// The returned order clause is safe against SQL injection; only
+// strings appearing from Table appear in the output.
+func (t *Table) OrderByClause(order []OrderBy) (string, error) {
+	if len(order) == 0 {
+		return "", nil
+	}
+	seenColumns := make(map[string]struct{})
+	var result strings.Builder
+	result.WriteString("ORDER BY ")
+	for i, o := range order {
+		if i > 0 {
+			result.WriteString(", ")
+		}
+		column, err := t.SortableColumnByName(o.Name)
+		if err != nil {
+			return "", err
+		}
+		if _, ok := seenColumns[column.databaseName]; ok {
+			return "", fmt.Errorf("field appears in order_by multiple times: %q", o.Name)
+		}
+		seenColumns[column.databaseName] = struct{}{}
+		result.WriteString(column.databaseName)
+		if o.Descending {
+			result.WriteString(" DESC")
+		}
+	}
+	result.WriteString("\n")
+	return result.String(), nil
+}
diff --git a/analysis/internal/aip/orderby_generator_test.go b/analysis/internal/aip/orderby_generator_test.go
new file mode 100644
index 0000000..7ead5ee
--- /dev/null
+++ b/analysis/internal/aip/orderby_generator_test.go
@@ -0,0 +1,132 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestOrderByClause(t *testing.T) {
+	Convey("OrderByClause", t, func() {
+		table := NewTable().WithColumns(
+			NewColumn().WithName("foo").WithDatabaseName("db_foo").Sortable().Build(),
+			NewColumn().WithName("bar").WithDatabaseName("db_bar").Sortable().Build(),
+			NewColumn().WithName("baz").WithDatabaseName("db_baz").Sortable().Build(),
+			NewColumn().WithName("unsortable").WithDatabaseName("unsortable").Build(),
+		).Build()
+
+		Convey("Empty order by", func() {
+			result, err := table.OrderByClause([]OrderBy{})
+			So(err, ShouldBeNil)
+			So(result, ShouldEqual, "")
+		})
+		Convey("Single order by", func() {
+			result, err := table.OrderByClause([]OrderBy{
+				{
+					Name: "foo",
+				},
+			})
+			So(err, ShouldBeNil)
+			So(result, ShouldEqual, "ORDER BY db_foo\n")
+		})
+		Convey("Multiple order by", func() {
+			result, err := table.OrderByClause([]OrderBy{
+				{
+					Name:       "foo",
+					Descending: true,
+				},
+				{
+					Name: "bar",
+				},
+				{
+					Name:       "baz",
+					Descending: true,
+				},
+			})
+			So(err, ShouldBeNil)
+			So(result, ShouldEqual, "ORDER BY db_foo DESC, db_bar, db_baz DESC\n")
+		})
+		Convey("Unsortable field in order by", func() {
+			_, err := table.OrderByClause([]OrderBy{
+				{
+					Name:       "unsortable",
+					Descending: true,
+				},
+			})
+			So(err, ShouldErrLike, `no sortable field named "unsortable", valid fields are foo, bar, baz`)
+		})
+		Convey("Repeated field in order by", func() {
+			_, err := table.OrderByClause([]OrderBy{
+				{
+					Name: "foo",
+				},
+				{
+					Name: "foo",
+				},
+			})
+			So(err, ShouldErrLike, `field appears in order_by multiple times: "foo"`)
+		})
+	})
+}
+
+func TestMergeWithDefaultOrder(t *testing.T) {
+	Convey("MergeWithDefaultOrder", t, func() {
+		defaultOrder := []OrderBy{
+			{
+				Name:       "foo",
+				Descending: true,
+			}, {
+				Name: "bar",
+			}, {
+				Name:       "baz",
+				Descending: true,
+			},
+		}
+		Convey("Empty order", func() {
+			result := MergeWithDefaultOrder(defaultOrder, nil)
+			So(result, ShouldResemble, defaultOrder)
+		})
+		Convey("Non-empty order", func() {
+			order := []OrderBy{
+				{
+					Name:       "other",
+					Descending: true,
+				},
+				{
+					Name: "baz",
+				},
+			}
+			result := MergeWithDefaultOrder(defaultOrder, order)
+			So(result, ShouldResemble, []OrderBy{
+				{
+					Name:       "other",
+					Descending: true,
+				},
+				{
+					Name: "baz",
+				},
+				{
+					Name:       "foo",
+					Descending: true,
+				}, {
+					Name: "bar",
+				},
+			})
+		})
+	})
+}
diff --git a/analysis/internal/aip/orderby_parser.go b/analysis/internal/aip/orderby_parser.go
new file mode 100644
index 0000000..5efe6a3
--- /dev/null
+++ b/analysis/internal/aip/orderby_parser.go
@@ -0,0 +1,62 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// This file contains a parser for AIP-132 order_by values.
+// See google.aip.dev/132. Field names are a sequence of one
+// or more identifiers matching the pattern [a-zA-Z0-9_]+,
+// separated by dots (".").
+
+// OrderBy represents a part of an AIP-132 order_by clause.
+type OrderBy struct {
+	// The name of the field. This is the externally-visible name
+	// of the field, not the database name.
+	Name string
+	// Whether the field should be sorted in descending order.
+	Descending bool
+}
+
+// columnOrderRE matches inputs like "some_field" or "some_field.child desc",
+// with arbitrary spacing.
+var columnOrderRE = regexp.MustCompile(`^ *(\w+(?:\.\w+)*) *( desc)? *$`)
+
+// ParseOrderBy parses the given AIP-132 order_by clause. OrderBy
+// directives are returned in the order they appear in the input.
+func ParseOrderBy(orderby string) ([]OrderBy, error) {
+	if strings.TrimSpace(orderby) == "" {
+		return nil, nil
+	}
+
+	columnOrder := strings.Split(orderby, ",")
+	result := make([]OrderBy, 0, len(columnOrder))
+	for _, co := range columnOrder {
+		parts := columnOrderRE.FindStringSubmatch(co)
+		if parts == nil {
+			return nil, fmt.Errorf("invalid ordering %q", co)
+		}
+
+		result = append(result, OrderBy{
+			Name:       parts[1],
+			Descending: parts[2] == " desc",
+		})
+	}
+	return result, nil
+}
diff --git a/analysis/internal/aip/orderby_parser_test.go b/analysis/internal/aip/orderby_parser_test.go
new file mode 100644
index 0000000..531be4c
--- /dev/null
+++ b/analysis/internal/aip/orderby_parser_test.go
@@ -0,0 +1,105 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package aip
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestParseOrderBy(t *testing.T) {
+	Convey("ParseOrderBy", t, func() {
+		// Test examples from the AIP-132 spec.
+		Convey("Values should be a comma separated list of fields", func() {
+			result, err := ParseOrderBy("foo,bar")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, []OrderBy{
+				{
+					Name: "foo",
+				},
+				{
+					Name: "bar",
+				},
+			})
+
+			result, err = ParseOrderBy("foo")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, []OrderBy{
+				{
+					Name: "foo",
+				},
+			})
+		})
+		Convey("The default sort order is ascending", func() {
+			result, err := ParseOrderBy("foo desc, bar")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, []OrderBy{
+				{
+					Name:       "foo",
+					Descending: true,
+				},
+				{
+					Name: "bar",
+				},
+			})
+		})
+		Convey("Redundant space characters in the syntax are insignificant", func() {
+			expectedResult := []OrderBy{
+				{
+					Name: "foo",
+				},
+				{
+					Name:       "bar",
+					Descending: true,
+				},
+			}
+			result, err := ParseOrderBy("foo, bar desc")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, expectedResult)
+
+			result, err = ParseOrderBy("  foo  ,  bar desc  ")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, expectedResult)
+
+			result, err = ParseOrderBy("foo,bar desc")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, expectedResult)
+		})
+		Convey("Subfields are specified with a . character", func() {
+			result, err := ParseOrderBy("foo.bar, foo.foo desc")
+			So(err, ShouldBeNil)
+			So(result, ShouldResemble, []OrderBy{
+				{
+					Name: "foo.bar",
+				},
+				{
+					Name:       "foo.foo",
+					Descending: true,
+				},
+			})
+		})
+		Convey("Invalid input is rejected", func() {
+			_, err := ParseOrderBy("`something")
+			So(err, ShouldErrLike, "invalid ordering \"`something\"")
+		})
+		Convey("Empty order by", func() {
+			result, err := ParseOrderBy("   ")
+			So(err, ShouldBeNil)
+			So(result, ShouldHaveLength, 0)
+		})
+	})
+}
diff --git a/analysis/internal/analysis/client.go b/analysis/internal/analysis/client.go
new file mode 100644
index 0000000..8fe07a5
--- /dev/null
+++ b/analysis/internal/analysis/client.go
@@ -0,0 +1,85 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+import (
+	"context"
+
+	"cloud.google.com/go/bigquery"
+	"go.chromium.org/luci/common/errors"
+	"google.golang.org/api/googleapi"
+	"google.golang.org/api/iterator"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+)
+
+// ProjectNotExistsErr is returned if the dataset for the given project
+// does not exist.
+var ProjectNotExistsErr = errors.New("project does not exist in Weetbix or analysis is not yet available")
+
+// InvalidArgumentTag is used to indicate that one of the query options
+// is invalid.
+var InvalidArgumentTag = errors.BoolTag{Key: errors.NewTagKey("invalid argument")}
+
+// NewClient creates a new client for reading clusters. Close() MUST
+// be called after you have finished using this client.
+func NewClient(ctx context.Context, gcpProject string) (*Client, error) {
+	client, err := bqutil.Client(ctx, gcpProject)
+	if err != nil {
+		return nil, err
+	}
+	return &Client{client: client}, nil
+}
+
+// Client may be used to read Weetbix clusters.
+type Client struct {
+	client *bigquery.Client
+}
+
+// Close releases any resources held by the client.
+func (c *Client) Close() error {
+	return c.client.Close()
+}
+
+// ProjectsWithDataset returns the set of LUCI projects which have
+// a BigQuery dataset created.
+func (c *Client) ProjectsWithDataset(ctx context.Context) (map[string]struct{}, error) {
+	result := make(map[string]struct{})
+	di := c.client.Datasets(ctx)
+	for {
+		d, err := di.Next()
+		if err == iterator.Done {
+			break
+		} else if err != nil {
+			return nil, err
+		}
+		project, err := bqutil.ProjectForDataset(d.DatasetID)
+		if err != nil {
+			return nil, err
+		}
+		result[project] = struct{}{}
+	}
+	return result, nil
+}
+
+func handleJobReadError(err error) error {
+	switch e := err.(type) {
+	case *googleapi.Error:
+		if e.Code == 404 {
+			return ProjectNotExistsErr
+		}
+	}
+	return errors.Annotate(err, "obtain result iterator").Err()
+}
diff --git a/analysis/internal/analysis/cluster_failures.go b/analysis/internal/analysis/cluster_failures.go
new file mode 100644
index 0000000..ee7930f
--- /dev/null
+++ b/analysis/internal/analysis/cluster_failures.go
@@ -0,0 +1,155 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+import (
+	"context"
+
+	"cloud.google.com/go/bigquery"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/trace"
+	"google.golang.org/api/iterator"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	"go.chromium.org/luci/analysis/internal/clustering"
+)
+
+type ClusterFailure struct {
+	Realm             bigquery.NullString `json:"realm"`
+	TestID            bigquery.NullString `json:"testId"`
+	Variant           []*Variant          `json:"variant"`
+	PresubmitRunID    *PresubmitRunID     `json:"presubmitRunId"`
+	PresubmitRunOwner bigquery.NullString `json:"presubmitRunOwner"`
+	PresubmitRunMode  bigquery.NullString `json:"presubmitRunMode"`
+	Changelists       []*Changelist
+	PartitionTime     bigquery.NullTimestamp `json:"partitionTime"`
+	Exonerations      []*Exoneration         `json:"exonerations"`
+	// weetbix.v1.BuildStatus, without "BUILD_STATUS_" prefix.
+	BuildStatus                 bigquery.NullString `json:"buildStatus"`
+	IsBuildCritical             bigquery.NullBool   `json:"isBuildCritical"`
+	IngestedInvocationID        bigquery.NullString `json:"ingestedInvocationId"`
+	IsIngestedInvocationBlocked bigquery.NullBool   `json:"isIngestedInvocationBlocked"`
+	Count                       int32               `json:"count"`
+}
+
+type Exoneration struct {
+	// weetbix.v1.ExonerationReason value. E.g. "OCCURS_ON_OTHER_CLS".
+	Reason bigquery.NullString `json:"reason"`
+}
+
+type Variant struct {
+	Key   bigquery.NullString `json:"key"`
+	Value bigquery.NullString `json:"value"`
+}
+
+type PresubmitRunID struct {
+	System bigquery.NullString `json:"system"`
+	ID     bigquery.NullString `json:"id"`
+}
+
+type Changelist struct {
+	Host     bigquery.NullString `json:"host"`
+	Change   bigquery.NullInt64  `json:"change"`
+	Patchset bigquery.NullInt64  `json:"patchset"`
+}
+
+type ReadClusterFailuresOptions struct {
+	// The LUCI Project.
+	Project   string
+	ClusterID clustering.ClusterID
+	Realms    []string
+}
+
+// ReadClusterFailures reads the latest 2000 groups of failures for a single cluster for the last 7 days.
+// A group of failures are failures that would be grouped together in MILO display, i.e.
+// same ingested_invocation_id, test_id and variant.
+func (c *Client) ReadClusterFailures(ctx context.Context, opts ReadClusterFailuresOptions) (cfs []*ClusterFailure, err error) {
+	_, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/analysis/ReadClusterFailures")
+	s.Attribute("project", opts.Project)
+	defer func() { s.End(err) }()
+
+	dataset, err := bqutil.DatasetForProject(opts.Project)
+	if err != nil {
+		return nil, errors.Annotate(err, "getting dataset").Err()
+	}
+	q := c.client.Query(`
+		WITH latest_failures_7d AS (
+			SELECT
+				cluster_algorithm,
+				cluster_id,
+				test_result_system,
+				test_result_id,
+				ARRAY_AGG(cf ORDER BY cf.last_updated DESC LIMIT 1)[OFFSET(0)] as r
+			FROM clustered_failures cf
+			WHERE cf.partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
+			  AND cluster_algorithm = @clusterAlgorithm
+			  AND cluster_id = @clusterID
+			  AND realm IN UNNEST(@realms)
+			GROUP BY cluster_algorithm, cluster_id, test_result_system, test_result_id
+			HAVING r.is_included
+		)
+		SELECT
+			r.realm as Realm,
+			r.test_id as TestID,
+			ANY_VALUE(r.variant) as Variant,
+			ANY_VALUE(r.presubmit_run_id) as PresubmitRunID,
+			ANY_VALUE(r.presubmit_run_owner) as PresubmitRunOwner,
+			ANY_VALUE(r.presubmit_run_mode) as PresubmitRunMode,
+			ANY_VALUE(r.changelists) as Changelists,
+			r.partition_time as PartitionTime,
+			ANY_VALUE(r.exonerations) as Exonerations,
+			ANY_VALUE(r.build_status) as BuildStatus,
+			ANY_VALUE(r.build_critical) as IsBuildCritical,
+			r.ingested_invocation_id as IngestedInvocationID,
+			ANY_VALUE(r.is_ingested_invocation_blocked) as IsIngestedInvocationBlocked,
+			count(*) as Count
+		FROM latest_failures_7d
+		GROUP BY
+			r.realm,
+			r.ingested_invocation_id,
+			r.test_id,
+			r.variant_hash,
+			r.partition_time
+		ORDER BY r.partition_time DESC
+		LIMIT 2000
+	`)
+	q.DefaultDatasetID = dataset
+	q.Parameters = []bigquery.QueryParameter{
+		{Name: "clusterAlgorithm", Value: opts.ClusterID.Algorithm},
+		{Name: "clusterID", Value: opts.ClusterID.ID},
+		{Name: "realms", Value: opts.Realms},
+	}
+	job, err := q.Run(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "querying cluster failures").Err()
+	}
+	it, err := job.Read(ctx)
+	if err != nil {
+		return nil, handleJobReadError(err)
+	}
+	failures := []*ClusterFailure{}
+	for {
+		row := &ClusterFailure{}
+		err := it.Next(row)
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return nil, errors.Annotate(err, "obtain next cluster failure row").Err()
+		}
+		failures = append(failures, row)
+	}
+	return failures, nil
+}
diff --git a/analysis/internal/analysis/cluster_summaries.go b/analysis/internal/analysis/cluster_summaries.go
new file mode 100644
index 0000000..755e0f3
--- /dev/null
+++ b/analysis/internal/analysis/cluster_summaries.go
@@ -0,0 +1,194 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+import (
+	"context"
+
+	"cloud.google.com/go/bigquery"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/trace"
+	"google.golang.org/api/iterator"
+
+	"go.chromium.org/luci/analysis/internal/aip"
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	"go.chromium.org/luci/analysis/internal/clustering"
+)
+
+var ClusteredFailuresTable = aip.NewTable().WithColumns(
+	aip.NewColumn().WithName("test_id").WithDatabaseName("test_id").FilterableImplicitly().Build(),
+	aip.NewColumn().WithName("failure_reason").WithDatabaseName("failure_reason.primary_error_message").FilterableImplicitly().Build(),
+	aip.NewColumn().WithName("realm").WithDatabaseName("realm").Filterable().Build(),
+	aip.NewColumn().WithName("ingested_invocation_id").WithDatabaseName("ingested_invocation_id").Filterable().Build(),
+	aip.NewColumn().WithName("cluster_algorithm").WithDatabaseName("cluster_algorithm").Filterable().Build(),
+	aip.NewColumn().WithName("cluster_id").WithDatabaseName("cluster_id").Filterable().Build(),
+	aip.NewColumn().WithName("variant_hash").WithDatabaseName("variant_hash").Filterable().Build(),
+	aip.NewColumn().WithName("test_run_id").WithDatabaseName("test_run_id").Filterable().Build(),
+).Build()
+
+var ClusterSummariesTable = aip.NewTable().WithColumns(
+	aip.NewColumn().WithName("presubmit_rejects").WithDatabaseName("PresubmitRejects").Sortable().Build(),
+	aip.NewColumn().WithName("critical_failures_exonerated").WithDatabaseName("CriticalFailuresExonerated").Sortable().Build(),
+	aip.NewColumn().WithName("failures").WithDatabaseName("Failures").Sortable().Build(),
+).Build()
+
+var ClusterSummariesDefaultOrder = []aip.OrderBy{
+	{Name: "presubmit_rejects", Descending: true},
+	{Name: "critical_failures_exonerated", Descending: true},
+	{Name: "failures", Descending: true},
+}
+
+type QueryClusterSummariesOptions struct {
+	// A filter on the underlying failures to include in the clusters.
+	FailureFilter *aip.Filter
+	OrderBy       []aip.OrderBy
+	Realms        []string
+}
+
+// ClusterSummary represents a summary of the cluster's failures
+// and their impact.
+type ClusterSummary struct {
+	ClusterID                  clustering.ClusterID
+	PresubmitRejects           int64
+	CriticalFailuresExonerated int64
+	Failures                   int64
+	ExampleFailureReason       bigquery.NullString
+	ExampleTestID              string
+	UniqueTestIDs              int64
+}
+
+// Queries a summary of clusters in the project.
+// The subset of failures included in the clustering may be filtered.
+// If the dataset for the LUCI project does not exist, returns
+// ProjectNotExistsErr.
+// If options.FailuresFilter or options.OrderBy is invalid with respect to the
+// query schema, returns an error tagged with InvalidArgumentTag so that the
+// appropriate gRPC error can be returned to the client (if applicable).
+func (c *Client) QueryClusterSummaries(ctx context.Context, luciProject string, options *QueryClusterSummariesOptions) (cs []*ClusterSummary, err error) {
+	_, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/analysis/QueryClusterSummaries")
+	s.Attribute("project", luciProject)
+	defer func() { s.End(err) }()
+
+	// Note that the content of the filter and order_by clause is untrusted
+	// user input and is validated as part of the Where/OrderBy clause
+	// generation here.
+	const parameterPrefix = "w_"
+	whereClause, parameters, err := ClusteredFailuresTable.WhereClause(options.FailureFilter, parameterPrefix)
+	if err != nil {
+		return nil, errors.Annotate(err, "failure_filter").Tag(InvalidArgumentTag).Err()
+	}
+
+	order := aip.MergeWithDefaultOrder(ClusterSummariesDefaultOrder, options.OrderBy)
+	orderByClause, err := ClusterSummariesTable.OrderByClause(order)
+	if err != nil {
+		return nil, errors.Annotate(err, "order_by").Tag(InvalidArgumentTag).Err()
+	}
+
+	dataset, err := bqutil.DatasetForProject(luciProject)
+	if err != nil {
+		return nil, errors.Annotate(err, "getting dataset").Err()
+	}
+	// The following query does not take into account removals of test failures
+	// from clusters as this dramatically slows down the query. Instead, we
+	// rely upon a periodic job to purge these results from the table.
+	// We avoid double-counting the test failures (e.g. in case of addition
+	// deletion, re-addition) by using APPROX_COUNT_DISTINCT to count the
+	// number of distinct failures in the cluster.
+	sql := `
+		SELECT
+			STRUCT(cluster_algorithm AS Algorithm,
+				cluster_id AS ID) AS ClusterID,
+			ANY_VALUE(failure_reason.primary_error_message) AS ExampleFailureReason,
+			MIN(test_id) AS ExampleTestID,
+			APPROX_COUNT_DISTINCT(test_id) AS UniqueTestIDs,
+			APPROX_COUNT_DISTINCT(presubmit_cl_blocked) AS PresubmitRejects,
+			APPROX_COUNT_DISTINCT(IF(is_critical_and_exonerated,unique_test_result_id, NULL)) AS CriticalFailuresExonerated,
+			APPROX_COUNT_DISTINCT(unique_test_result_id) AS Failures,
+		FROM (
+			SELECT
+				cluster_algorithm,
+				cluster_id,
+				test_id,
+				failure_reason,
+				CONCAT(chunk_id, '/', COALESCE(chunk_index, 0)) as unique_test_result_id,
+				(build_critical AND
+				-- Exonerated for a reason other than NOT_CRITICAL or UNEXPECTED_PASS.
+				-- Passes are not ingested by Weetbix, but if a test has both an unexpected pass
+				-- and an unexpected failure, it will be exonerated for the unexpected pass.
+				(STRUCT('OCCURS_ON_MAINLINE' as Reason) in UNNEST(exonerations) OR
+					STRUCT('OCCURS_ON_OTHER_CLS' as Reason) in UNNEST(exonerations)))
+				AS is_critical_and_exonerated,
+				IF(is_ingested_invocation_blocked AND build_critical AND presubmit_run_mode = 'FULL_RUN' AND
+				ARRAY_LENGTH(exonerations) = 0 AND build_status = 'FAILURE' AND presubmit_run_owner = 'user',
+					IF(ARRAY_LENGTH(changelists)>0 AND presubmit_run_owner='user',
+					CONCAT(changelists[OFFSET(0)].host, changelists[OFFSET(0)].change),
+					NULL),
+					NULL)
+				AS presubmit_cl_blocked,
+			FROM clustered_failures cf
+			WHERE
+				is_included_with_high_priority
+				AND partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
+				AND ` + whereClause + `
+				AND realm IN UNNEST(@realms)
+		)
+		GROUP BY
+			cluster_algorithm,
+			cluster_id
+		` + orderByClause + `
+		LIMIT 1000
+	`
+
+	q := c.client.Query(sql)
+	q.DefaultDatasetID = dataset
+	q.Parameters = toBigQueryParameters(parameters)
+	q.Parameters = append(q.Parameters, bigquery.QueryParameter{
+		Name:  "realms",
+		Value: options.Realms,
+	})
+
+	job, err := q.Run(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "querying cluster summaries").Err()
+	}
+	it, err := job.Read(ctx)
+	if err != nil {
+		return nil, handleJobReadError(err)
+	}
+	clusters := []*ClusterSummary{}
+	for {
+		row := &ClusterSummary{}
+		err := it.Next(row)
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return nil, errors.Annotate(err, "obtain next cluster summary row").Err()
+		}
+		clusters = append(clusters, row)
+	}
+	return clusters, nil
+}
+
+func toBigQueryParameters(pars []aip.QueryParameter) []bigquery.QueryParameter {
+	result := make([]bigquery.QueryParameter, 0, len(pars))
+	for _, p := range pars {
+		result = append(result, bigquery.QueryParameter{
+			Name:  p.Name,
+			Value: p.Value,
+		})
+	}
+	return result
+}
diff --git a/analysis/internal/analysis/clusteredfailures/client.go b/analysis/internal/analysis/clusteredfailures/client.go
new file mode 100644
index 0000000..6a56f84
--- /dev/null
+++ b/analysis/internal/analysis/clusteredfailures/client.go
@@ -0,0 +1,184 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clusteredfailures
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+
+	"cloud.google.com/go/bigquery"
+	"cloud.google.com/go/bigquery/storage/managedwriter"
+	"google.golang.org/api/option"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/keepalive"
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/grpc/grpcmon"
+	"go.chromium.org/luci/server/auth"
+)
+
+// batchSize is the number of rows to write to BigQuery in one go.
+const batchSize = 1000
+
+// NewClient creates a new client for exporting clustered failures
+// via the BigQuery Write API.
+func NewClient(ctx context.Context, projectID string) (s *Client, reterr error) {
+	if projectID == "" {
+		return nil, errors.New("GCP Project must be specified")
+	}
+
+	bqClient, err := bqutil.Client(ctx, projectID)
+	if err != nil {
+		return nil, errors.Annotate(err, "creating BQ client").Err()
+	}
+	defer func() {
+		if reterr != nil {
+			bqClient.Close()
+		}
+	}()
+
+	// Create shared client for all writes.
+	// This will ensure a shared connection pool is used for all writes,
+	// as recommended by:
+	// https://cloud.google.com/bigquery/docs/write-api-best-practices#limit_the_number_of_concurrent_connections
+	creds, err := auth.GetPerRPCCredentials(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
+	if err != nil {
+		return nil, errors.Annotate(err, "failed to initialize credentials").Err()
+	}
+	mwClient, err := managedwriter.NewClient(ctx, projectID,
+		option.WithGRPCDialOption(grpcmon.WithClientRPCStatsMonitor()),
+		option.WithGRPCDialOption(grpc.WithPerRPCCredentials(creds)),
+		option.WithGRPCDialOption(grpc.WithKeepaliveParams(keepalive.ClientParameters{
+			Time: time.Minute,
+		})))
+	if err != nil {
+		return nil, errors.Annotate(err, "create managed writer client").Err()
+	}
+
+	return &Client{
+		projectID: projectID,
+		bqClient:  bqClient,
+		mwClient:  mwClient,
+	}, nil
+}
+
+// Close releases resources held by the client.
+func (s *Client) Close() (reterr error) {
+	// Ensure all Close() methods are called, even if one panics or fails.
+	defer func() {
+		err := s.mwClient.Close()
+		if reterr == nil {
+			reterr = err
+		}
+	}()
+	return s.bqClient.Close()
+}
+
+// Client provides methods to export clustered failures to BigQuery
+// via the BigQuery Write API.
+type Client struct {
+	// projectID is the name of the GCP project that contains Weetbix datasets.
+	projectID string
+	bqClient  *bigquery.Client
+	mwClient  *managedwriter.Client
+}
+
+func (s *Client) ensureSchema(ctx context.Context, datasetID string) error {
+	// Dataset for the project may have to be manually created.
+	table := s.bqClient.Dataset(datasetID).Table(tableName)
+	if err := schemaApplyer.EnsureTable(ctx, table, tableMetadata); err != nil {
+		return errors.Annotate(err, "ensuring clustered failures table in dataset %q", datasetID).Err()
+	}
+	return nil
+}
+
+// Insert inserts the given rows in BigQuery.
+func (s *Client) Insert(ctx context.Context, luciProject string, rows []*bqpb.ClusteredFailureRow) error {
+	dataset, err := bqutil.DatasetForProject(luciProject)
+	if err != nil {
+		return errors.Annotate(err, "getting dataset").Err()
+	}
+
+	if err := s.ensureSchema(ctx, dataset); err != nil {
+		return errors.Annotate(err, "ensure schema").Err()
+	}
+
+	tableName := fmt.Sprintf("projects/%s/datasets/%s/tables/%s", s.projectID, dataset, tableName)
+
+	// Write to the default stream. This does not provide exactly-once
+	// semantics (it provides at leas once), but this should be generally
+	// fine for our needs. The at least once semantic is similar to the
+	// legacy streaming API.
+	ms, err := s.mwClient.NewManagedStream(ctx,
+		managedwriter.WithSchemaDescriptor(tableSchemaDescriptor),
+		managedwriter.WithDestinationTable(tableName))
+	defer ms.Close()
+
+	batches := batch(rows)
+	results := make([]*managedwriter.AppendResult, 0, len(batches))
+
+	for _, batch := range batches {
+		encoded := make([][]byte, 0, len(batch))
+		for _, r := range batch {
+			b, err := proto.Marshal(r)
+			if err != nil {
+				return errors.Annotate(err, "marshal proto").Err()
+			}
+			encoded = append(encoded, b)
+		}
+
+		result, err := ms.AppendRows(ctx, encoded)
+		if err != nil {
+			return errors.Annotate(err, "start appending rows").Err()
+		}
+
+		// Defer waiting on AppendRows until after all batches sent out.
+		// https://cloud.google.com/bigquery/docs/write-api-best-practices#do_not_block_on_appendrows_calls
+		results = append(results, result)
+	}
+	for _, result := range results {
+		// TODO: In future, we might need to apply some sort of retry
+		// logic around batches as we did for legacy streaming writes
+		// for quota issues.
+		// That said, the client library here should deal with standard
+		// BigQuery retries and backoffs.
+		_, err = result.GetResult(ctx)
+		if err != nil {
+			return errors.Annotate(err, "appending rows").Err()
+		}
+	}
+	return nil
+}
+
+// batch divides the rows to be inserted into batches of at most batchSize.
+func batch(rows []*bqpb.ClusteredFailureRow) [][]*bqpb.ClusteredFailureRow {
+	var result [][]*bqpb.ClusteredFailureRow
+	pages := (len(rows) + (batchSize - 1)) / batchSize
+	for p := 0; p < pages; p++ {
+		start := p * batchSize
+		end := start + batchSize
+		if end > len(rows) {
+			end = len(rows)
+		}
+		page := rows[start:end]
+		result = append(result, page)
+	}
+	return result
+}
diff --git a/analysis/internal/analysis/clusteredfailures/fake.go b/analysis/internal/analysis/clusteredfailures/fake.go
new file mode 100644
index 0000000..58b27ee
--- /dev/null
+++ b/analysis/internal/analysis/clusteredfailures/fake.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clusteredfailures
+
+import (
+	"context"
+
+	bqp "go.chromium.org/luci/analysis/proto/bq"
+)
+
+// FakeClient represents a fake implementation of the clustered failures
+// exporter, for testing.
+type FakeClient struct {
+	InsertionsByProject map[string][]*bqp.ClusteredFailureRow
+}
+
+// NewFakeClient creates a new FakeClient for exporting clustered failures.
+func NewFakeClient() *FakeClient {
+	return &FakeClient{
+		InsertionsByProject: make(map[string][]*bqp.ClusteredFailureRow),
+	}
+}
+
+// Insert inserts the given rows in BigQuery.
+func (fc *FakeClient) Insert(ctx context.Context, luciProject string, rows []*bqp.ClusteredFailureRow) error {
+	inserts := fc.InsertionsByProject[luciProject]
+	inserts = append(inserts, rows...)
+	fc.InsertionsByProject[luciProject] = inserts
+	return nil
+}
diff --git a/analysis/internal/analysis/clusteredfailures/schema.go b/analysis/internal/analysis/clusteredfailures/schema.go
new file mode 100644
index 0000000..f0a557b
--- /dev/null
+++ b/analysis/internal/analysis/clusteredfailures/schema.go
@@ -0,0 +1,93 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clusteredfailures
+
+import (
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	"cloud.google.com/go/bigquery/storage/managedwriter/adapt"
+	"github.com/golang/protobuf/descriptor"
+	desc "github.com/golang/protobuf/protoc-gen-go/descriptor"
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/server/caching"
+	"google.golang.org/protobuf/types/descriptorpb"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// tableName is the name of the exported BigQuery table.
+const tableName = "clustered_failures"
+
+// schemaApplyer ensures BQ schema matches the row proto definitions.
+var schemaApplyer = bq.NewSchemaApplyer(caching.RegisterLRUCache(50))
+
+const partitionExpirationTime = 90 * 24 * time.Hour
+
+const rowMessage = "weetbix.bq.ClusteredFailureRow"
+
+var tableMetadata *bigquery.TableMetadata
+
+// tableSchemaDescriptor is a self-contained DescriptorProto for describing
+// row protocol buffers sent to the BigQuery Write API.
+var tableSchemaDescriptor *descriptorpb.DescriptorProto
+
+func init() {
+	var err error
+	var schema bigquery.Schema
+	if schema, err = generateRowSchema(); err != nil {
+		panic(err)
+	}
+	if tableSchemaDescriptor, err = generateRowSchemaDescriptor(); err != nil {
+		panic(err)
+	}
+
+	tableMetadata = &bigquery.TableMetadata{
+		TimePartitioning: &bigquery.TimePartitioning{
+			Type:       bigquery.DayPartitioningType,
+			Expiration: partitionExpirationTime,
+			Field:      "partition_time",
+		},
+		Clustering: &bigquery.Clustering{
+			Fields: []string{"cluster_algorithm", "cluster_id", "test_result_system", "test_result_id"},
+		},
+		// Relax ensures no fields are marked "required".
+		Schema: schema.Relax(),
+	}
+}
+
+func generateRowSchema() (schema bigquery.Schema, err error) {
+	fd, _ := descriptor.MessageDescriptorProto(&bqpb.ClusteredFailureRow{})
+	// We also need to get FileDescriptorProto for StringPair, BugTrackingComponent, FailureReason
+	// and PresubmitRunId because they are defined in different files.
+	fdsp, _ := descriptor.MessageDescriptorProto(&pb.StringPair{})
+	fdbtc, _ := descriptor.MessageDescriptorProto(&pb.BugTrackingComponent{})
+	fdfr, _ := descriptor.MessageDescriptorProto(&pb.FailureReason{})
+	fdprid, _ := descriptor.MessageDescriptorProto(&pb.PresubmitRunId{})
+	fdcl, _ := descriptor.MessageDescriptorProto(&pb.Changelist{})
+	fdset := &desc.FileDescriptorSet{File: []*desc.FileDescriptorProto{fd, fdsp, fdbtc, fdfr, fdprid, fdcl}}
+	return bqutil.GenerateSchema(fdset, rowMessage)
+}
+
+func generateRowSchemaDescriptor() (*desc.DescriptorProto, error) {
+	m := &bqpb.ClusteredFailureRow{}
+	descriptorProto, err := adapt.NormalizeDescriptor(m.ProtoReflect().Descriptor())
+	if err != nil {
+		return nil, err
+	}
+	return descriptorProto, nil
+}
diff --git a/analysis/internal/analysis/clusteredfailures/schema_test.go b/analysis/internal/analysis/clusteredfailures/schema_test.go
new file mode 100644
index 0000000..29902e1
--- /dev/null
+++ b/analysis/internal/analysis/clusteredfailures/schema_test.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clusteredfailures
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSchema(t *testing.T) {
+	t.Parallel()
+	Convey(`With Schema`, t, func() {
+		var fieldNames []string
+		for _, field := range tableMetadata.Schema {
+			fieldNames = append(fieldNames, field.Name)
+		}
+		Convey(`Time partitioning field is defined`, func() {
+			for _, clusteringField := range tableMetadata.Clustering.Fields {
+				So(clusteringField, ShouldBeIn, fieldNames)
+			}
+		})
+		Convey(`Clustering fields are defined`, func() {
+			for _, clusteringField := range tableMetadata.Clustering.Fields {
+				So(clusteringField, ShouldBeIn, fieldNames)
+			}
+		})
+	})
+}
diff --git a/analysis/internal/analysis/clustering_handler.go b/analysis/internal/analysis/clustering_handler.go
new file mode 100644
index 0000000..08765b4
--- /dev/null
+++ b/analysis/internal/analysis/clustering_handler.go
@@ -0,0 +1,200 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+import (
+	"context"
+	"time"
+
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// ClusteringHandler handles test result (re-)clustering events, to
+// ensure analysis remains up-to-date.
+type ClusteringHandler struct {
+	cfClient ClusteredFailuresClient
+}
+
+// ClusteredFailuresClient exports clustered failures to BigQuery for
+// further analysis.
+type ClusteredFailuresClient interface {
+	// Insert inserts the given rows into BigQuery.
+	Insert(ctx context.Context, luciProject string, rows []*bqpb.ClusteredFailureRow) error
+}
+
+func NewClusteringHandler(cf ClusteredFailuresClient) *ClusteringHandler {
+	return &ClusteringHandler{
+		cfClient: cf,
+	}
+}
+
+// HandleUpdatedClusters handles (re-)clustered test results. It is called
+// after the spanner transaction effecting the (re-)clustering has committed.
+// commitTime is the Spanner time the transaction committed.
+//
+// If this method fails, it will not be retried and data loss or inconsistency
+// (in this method's BigQuery export) may occur. This could be improved in
+// future with a two-stage apply process (journalling the BigQuery updates
+// to be applied as part of the original transaction and retrying them at
+// a later point if they do not succeed).
+func (r *ClusteringHandler) HandleUpdatedClusters(ctx context.Context, updates *clustering.Update, commitTime time.Time) error {
+	rowUpdates := prepareInserts(updates, commitTime)
+	return r.cfClient.Insert(ctx, updates.Project, rowUpdates)
+}
+
+// prepareInserts prepares entries into the BigQuery clustered failures table
+// in response to a (re-)clustering. For efficiency, only the updated rows are
+// returned.
+func prepareInserts(updates *clustering.Update, commitTime time.Time) []*bqpb.ClusteredFailureRow {
+	var result []*bqpb.ClusteredFailureRow
+	for _, u := range updates.Updates {
+		deleted := make(map[string]clustering.ClusterID)
+		retained := make(map[string]clustering.ClusterID)
+		new := make(map[string]clustering.ClusterID)
+
+		previousInBugCluster := false
+		for _, pc := range u.PreviousClusters {
+			deleted[pc.Key()] = pc
+			if pc.IsBugCluster() {
+				previousInBugCluster = true
+			}
+		}
+		newInBugCluster := false
+		for _, nc := range u.NewClusters {
+			key := nc.Key()
+			if _, ok := deleted[key]; ok {
+				delete(deleted, key)
+				retained[key] = nc
+			} else {
+				new[key] = nc
+			}
+			if nc.IsBugCluster() {
+				newInBugCluster = true
+			}
+		}
+		// Create rows for deletions.
+		for _, dc := range deleted {
+			isIncluded := false
+			isIncludedWithHighPriority := false
+			row := entryFromUpdate(updates.Project, updates.ChunkID, dc, u.TestResult, isIncluded, isIncludedWithHighPriority, commitTime)
+			result = append(result, row)
+		}
+		// Create rows for retained clusters for which inclusion was modified.
+		for _, rc := range retained {
+			isIncluded := true
+			// A failure will appear with high priority in any bug clusters
+			// it appears in, and if it appears in no bug clusters, it will
+			// appear with high priority in any suggested clusters it appears
+			// in.
+			previousIncludedWithHighPriority := rc.IsBugCluster() || !previousInBugCluster
+			newIncludedWithHighPriority := rc.IsBugCluster() || !newInBugCluster
+			if previousIncludedWithHighPriority == newIncludedWithHighPriority {
+				// The inclusion status of the test result in the cluster has not changed.
+				// For efficiency, do not stream an update.
+				continue
+			}
+			row := entryFromUpdate(updates.Project, updates.ChunkID, rc, u.TestResult, isIncluded, newIncludedWithHighPriority, commitTime)
+			result = append(result, row)
+		}
+		// Create rows for new clusters.
+		for _, nc := range new {
+			isIncluded := true
+			// A failure will appear with high priority in any bug clusters
+			// it appears in, and if it appears in no bug clusters, it will
+			// appear with high priority in any suggested clusters it appears
+			// in.
+			isIncludedWithHighPriority := nc.IsBugCluster() || !newInBugCluster
+			row := entryFromUpdate(updates.Project, updates.ChunkID, nc, u.TestResult, isIncluded, isIncludedWithHighPriority, commitTime)
+			result = append(result, row)
+		}
+	}
+	return result
+}
+
+func entryFromUpdate(project, chunkID string, cluster clustering.ClusterID, failure *cpb.Failure, included, includedWithHighPriority bool, commitTime time.Time) *bqpb.ClusteredFailureRow {
+	// Copy the failure, to ensure the returned ClusteredFailure does not
+	// alias any of the original failure's nested message protos.
+	failure = proto.Clone(failure).(*cpb.Failure)
+
+	exonerations := make([]*bqpb.ClusteredFailureRow_TestExoneration, 0, len(failure.Exonerations))
+	for _, e := range failure.Exonerations {
+		exonerations = append(exonerations, &bqpb.ClusteredFailureRow_TestExoneration{
+			Reason: e.Reason,
+		})
+	}
+
+	entry := &bqpb.ClusteredFailureRow{
+		ClusterAlgorithm: cluster.Algorithm,
+		ClusterId:        cluster.ID,
+		TestResultSystem: failure.TestResultId.System,
+		TestResultId:     failure.TestResultId.Id,
+		LastUpdated:      timestamppb.New(commitTime),
+
+		PartitionTime: failure.PartitionTime,
+
+		IsIncluded:                 included,
+		IsIncludedWithHighPriority: includedWithHighPriority,
+
+		ChunkId:    chunkID,
+		ChunkIndex: failure.ChunkIndex,
+
+		Realm:                failure.Realm,
+		TestId:               failure.TestId,
+		Variant:              variantPairs(failure.Variant),
+		Tags:                 failure.Tags,
+		VariantHash:          failure.VariantHash,
+		FailureReason:        failure.FailureReason,
+		BugTrackingComponent: failure.BugTrackingComponent,
+		StartTime:            failure.StartTime,
+		Duration:             failure.Duration.AsDuration().Seconds(),
+		Exonerations:         exonerations,
+
+		BuildStatus:                   ToBQBuildStatus(failure.BuildStatus),
+		BuildCritical:                 failure.BuildCritical != nil && *failure.BuildCritical,
+		Changelists:                   failure.Changelists,
+		IngestedInvocationId:          failure.IngestedInvocationId,
+		IngestedInvocationResultIndex: failure.IngestedInvocationResultIndex,
+		IngestedInvocationResultCount: failure.IngestedInvocationResultCount,
+		IsIngestedInvocationBlocked:   failure.IsIngestedInvocationBlocked,
+		TestRunId:                     failure.TestRunId,
+		TestRunResultIndex:            failure.TestRunResultIndex,
+		TestRunResultCount:            failure.TestRunResultCount,
+		IsTestRunBlocked:              failure.IsTestRunBlocked,
+	}
+	if failure.PresubmitRun != nil {
+		entry.PresubmitRunId = failure.PresubmitRun.PresubmitRunId
+		entry.PresubmitRunOwner = failure.PresubmitRun.Owner
+		entry.PresubmitRunMode = ToBQPresubmitRunMode(failure.PresubmitRun.Mode)
+		entry.PresubmitRunStatus = ToBQPresubmitRunStatus(failure.PresubmitRun.Status)
+	}
+	return entry
+}
+
+func variantPairs(v *pb.Variant) []*pb.StringPair {
+	var result []*pb.StringPair
+	for k, v := range v.Def {
+		result = append(result, &pb.StringPair{
+			Key:   k,
+			Value: v,
+		})
+	}
+	return result
+}
diff --git a/analysis/internal/analysis/clusters.go b/analysis/internal/analysis/clusters.go
new file mode 100644
index 0000000..fff7790
--- /dev/null
+++ b/analysis/internal/analysis/clusters.go
@@ -0,0 +1,418 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+import (
+	"context"
+	"math"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/trace"
+	"google.golang.org/api/iterator"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// Cluster contains detailed information about a cluster, including
+// a statistical summary of a cluster's failures, and their impact.
+type Cluster struct {
+	ClusterID clustering.ClusterID `json:"clusterId"`
+	// Distinct user CLs with presubmit rejects.
+	PresubmitRejects1d Counts `json:"presubmitRejects1d"`
+	PresubmitRejects3d Counts `json:"presubmitRejects3d"`
+	PresubmitRejects7d Counts `json:"presubmitRejects7d"`
+	// Distinct test runs failed.
+	TestRunFails1d Counts `json:"testRunFailures1d"`
+	TestRunFails3d Counts `json:"testRunFailures3d"`
+	TestRunFails7d Counts `json:"testRunFailures7d"`
+	// Total test results with unexpected failures.
+	Failures1d Counts `json:"failures1d"`
+	Failures3d Counts `json:"failures3d"`
+	Failures7d Counts `json:"failures7d"`
+	// Test failures exonerated on critical builders, and for an
+	// exoneration reason other than NOT_CRITICAL.
+	CriticalFailuresExonerated1d Counts `json:"criticalFailuresExonerated1d"`
+	CriticalFailuresExonerated3d Counts `json:"criticalFailuresExonerated3d"`
+	CriticalFailuresExonerated7d Counts `json:"criticalFailuresExonerated7d"`
+
+	// The realm(s) examples of the cluster are present in.
+	Realms               []string
+	ExampleFailureReason bigquery.NullString `json:"exampleFailureReason"`
+	// Top Test IDs included in the cluster, up to 5. Unless the cluster
+	// is empty, will always include at least one Test ID.
+	TopTestIDs []TopCount `json:"topTestIds"`
+	// Top Monorail Components indicates the top monorail components failures
+	// in the cluster are associated with by number of failures, up to 5.
+	TopMonorailComponents []TopCount `json:"topMonorailComponents"`
+}
+
+// ExampleTestID returns an example Test ID that is part of the cluster, or
+// "" if the cluster is empty.
+func (s *Cluster) ExampleTestID() string {
+	if len(s.TopTestIDs) > 0 {
+		return s.TopTestIDs[0].Value
+	}
+	return ""
+}
+
+// Counts captures the values of an integer-valued metric in different
+// calculation bases.
+type Counts struct {
+	// The statistic value after impact has been reduced by exoneration.
+	Nominal int64 `json:"nominal"`
+	// The statistic value:
+	// - excluding impact already counted under other higher-priority clusters
+	//   (I.E. bug clusters.)
+	// - after impact has been reduced by exoneration.
+	Residual int64 `json:"residual"`
+}
+
+// TopCount captures the result of the APPROX_TOP_COUNT operator. See:
+// https://cloud.google.com/bigquery/docs/reference/standard-sql/approximate_aggregate_functions#approx_top_count
+type TopCount struct {
+	// Value is the value that was frequently occurring.
+	Value string `json:"value"`
+	// Count is the frequency with which the value occurred.
+	Count int64 `json:"count"`
+}
+
+// RebuildAnalysis re-builds the cluster summaries analysis from
+// clustered test results.
+func (c *Client) RebuildAnalysis(ctx context.Context, luciProject string) error {
+	datasetID, err := bqutil.DatasetForProject(luciProject)
+	if err != nil {
+		return errors.Annotate(err, "getting dataset").Err()
+	}
+	dataset := c.client.Dataset(datasetID)
+
+	dstTable := dataset.Table("cluster_summaries")
+
+	q := c.client.Query(clusterAnalysis)
+	q.DefaultDatasetID = dataset.DatasetID
+	q.Dst = dstTable
+	q.CreateDisposition = bigquery.CreateIfNeeded
+	q.WriteDisposition = bigquery.WriteTruncate
+	job, err := q.Run(ctx)
+	if err != nil {
+		return errors.Annotate(err, "starting cluster summary analysis").Err()
+	}
+
+	waitCtx, cancel := context.WithTimeout(ctx, time.Minute*5)
+	defer cancel()
+
+	js, err := job.Wait(waitCtx)
+	if err != nil {
+		return errors.Annotate(err, "waiting for cluster summary analysis to complete").Err()
+	}
+	if js.Err() != nil {
+		return errors.Annotate(err, "cluster summary analysis failed").Err()
+	}
+	return nil
+}
+
+// PurgeStaleRows purges stale clustered failure rows from the table.
+// Stale rows are those rows which have been superseded by a new row with a later
+// version, or where the latest version of the row has the row not included in a
+// cluster.
+// This is necessary for:
+// - Our QueryClusterSummaries query, which for performance reasons (UI-interactive)
+//   does not do filtering to fetch the latest version of rows and instead uses all
+//   rows.
+// - Keeping the size of the BigQuery table to a minimum.
+// We currently only purge the last 7 days to keep purging costs to a minimum and
+// as this is as far as QueryClusterSummaries looks back.
+func (c *Client) PurgeStaleRows(ctx context.Context, luciProject string) error {
+	datasetID, err := bqutil.DatasetForProject(luciProject)
+	if err != nil {
+		return errors.Annotate(err, "getting dataset").Err()
+	}
+	dataset := c.client.Dataset(datasetID)
+
+	// If something goes wrong with this statement it deletes everything
+	// for some reason, the system can be restored as follows:
+	// - Fix the statement.
+	// - Bump the algorithm version on all algorithms, to trigger a
+	//   re-clustering and re-export of all test results.
+	q := c.client.Query(`
+		DELETE FROM clustered_failures cf1
+		WHERE
+			cf1.partition_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) AND
+			-- Not in the streaming buffer. Streaming buffer keeps up to
+			-- 30 minutes of data. We use 40 minutes here to allow some
+			-- margin as our last_updated timestamp is the timestamp
+			-- the chunk was committed in Spanner and export to BigQuery
+			-- can be delayed from that.
+			cf1.last_updated < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 40 MINUTE) AND
+			(
+				-- Not the latest (cluster, test result) entry.
+				cf1.last_updated < (SELECT MAX(cf2.last_updated)
+								FROM clustered_failures cf2
+								WHERE cf2.partition_time > TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
+									AND cf2.partition_time = cf1.partition_time
+									AND cf2.cluster_algorithm = cf1.cluster_algorithm
+									AND cf2.cluster_id = cf1.cluster_id
+									AND cf2.chunk_id = cf1.chunk_id
+									AND cf2.chunk_index = cf1.chunk_index
+									)
+				-- Or is the latest (cluster, test result) entry, but test result
+				-- is no longer in cluster.
+				OR NOT cf1.is_included
+			)
+	`)
+	q.DefaultDatasetID = dataset.DatasetID
+
+	job, err := q.Run(ctx)
+	if err != nil {
+		return errors.Annotate(err, "purge stale rows").Err()
+	}
+
+	waitCtx, cancel := context.WithTimeout(ctx, time.Minute*5)
+	defer cancel()
+
+	js, err := job.Wait(waitCtx)
+	if err != nil {
+		// BigQuery specifies that rows are kept in the streaming buffer for
+		// 30 minutes, but sometimes exceeds this SLO. We could be less
+		// aggressive at deleting rows, but that would make the average-case
+		// experience worse. These errors should only occur occasionally,
+		// so it is better to ignore them.
+		if strings.Contains(err.Error(), "would affect rows in the streaming buffer, which is not supported") {
+			logging.Warningf(ctx, "Row purge failed for %v because rows were in the streaming buffer for over 30 minutes. "+
+				"If this message occurs more than 25 percent of the time, it should be investigated.", luciProject)
+			return nil
+		}
+		return errors.Annotate(err, "waiting for stale row purge to complete").Err()
+	}
+	if js.Err() != nil {
+		return errors.Annotate(err, "purge stale rows failed").Err()
+	}
+	return nil
+}
+
+// ReadCluster reads information about a list of clusters.
+// If the dataset for the LUCI project does not exist, returns ProjectNotExistsErr.
+func (c *Client) ReadClusters(ctx context.Context, luciProject string, clusterIDs []clustering.ClusterID) (cs []*Cluster, err error) {
+	_, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/analysis/ReadClusters")
+	s.Attribute("project", luciProject)
+	defer func() { s.End(err) }()
+
+	dataset, err := bqutil.DatasetForProject(luciProject)
+	if err != nil {
+		return nil, errors.Annotate(err, "getting dataset").Err()
+	}
+
+	q := c.client.Query(`
+		SELECT
+			STRUCT(cluster_algorithm AS Algorithm, cluster_id as ID) as ClusterID,` +
+		selectCounts("critical_failures_exonerated", "CriticalFailuresExonerated", "1d") +
+		selectCounts("critical_failures_exonerated", "CriticalFailuresExonerated", "3d") +
+		selectCounts("critical_failures_exonerated", "CriticalFailuresExonerated", "7d") +
+		selectCounts("presubmit_rejects", "PresubmitRejects", "1d") +
+		selectCounts("presubmit_rejects", "PresubmitRejects", "3d") +
+		selectCounts("presubmit_rejects", "PresubmitRejects", "7d") +
+		selectCounts("test_run_fails", "TestRunFails", "1d") +
+		selectCounts("test_run_fails", "TestRunFails", "3d") +
+		selectCounts("test_run_fails", "TestRunFails", "7d") +
+		selectCounts("failures", "Failures", "1d") +
+		selectCounts("failures", "Failures", "3d") +
+		selectCounts("failures", "Failures", "7d") + `
+		    realms as Realms,
+			example_failure_reason.primary_error_message as ExampleFailureReason,
+			top_test_ids as TopTestIDs
+		FROM cluster_summaries
+		WHERE STRUCT(cluster_algorithm AS Algorithm, cluster_id as ID) IN UNNEST(@clusterIDs)
+	`)
+	q.DefaultDatasetID = dataset
+	q.Parameters = []bigquery.QueryParameter{
+		{Name: "clusterIDs", Value: clusterIDs},
+	}
+	job, err := q.Run(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "querying cluster").Err()
+	}
+	it, err := job.Read(ctx)
+	if err != nil {
+		return nil, handleJobReadError(err)
+	}
+	clusters := []*Cluster{}
+	for {
+		row := &Cluster{}
+		err := it.Next(row)
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return nil, errors.Annotate(err, "obtain next cluster row").Err()
+		}
+		clusters = append(clusters, row)
+	}
+	return clusters, nil
+}
+
+// ImpactfulClusterReadOptions specifies options for ReadImpactfulClusters().
+type ImpactfulClusterReadOptions struct {
+	// Project is the LUCI Project for which analysis is being performed.
+	Project string
+	// Thresholds is the set of thresholds, which if any are met
+	// or exceeded, should result in the cluster being returned.
+	// Thresholds are applied based on the residual pre-Weetbix (exoneration)
+	// cluster impact.
+	Thresholds *configpb.ImpactThreshold
+	// AlwaysIncludeBugClusters controls whether to include analysis for all
+	// bug clusters.
+	AlwaysIncludeBugClusters bool
+}
+
+// ReadImpactfulClusters reads clusters exceeding specified impact metrics, or are otherwise
+// nominated to be read.
+func (c *Client) ReadImpactfulClusters(ctx context.Context, opts ImpactfulClusterReadOptions) (cs []*Cluster, err error) {
+	_, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/analysis/ReadImpactfulClusters")
+	s.Attribute("project", opts.Project)
+	defer func() { s.End(err) }()
+
+	if opts.Thresholds == nil {
+		return nil, errors.New("thresholds must be specified")
+	}
+
+	dataset, err := bqutil.DatasetForProject(opts.Project)
+	if err != nil {
+		return nil, errors.Annotate(err, "getting dataset").Err()
+	}
+
+	whereCriticalFailuresExonerated, cfeParams := whereThresholdsMet("critical_failures_exonerated", opts.Thresholds.CriticalFailuresExonerated)
+	whereFailures, failuresParams := whereThresholdsMet("failures", opts.Thresholds.TestResultsFailed)
+	whereTestRuns, testRunsParams := whereThresholdsMet("test_run_fails", opts.Thresholds.TestRunsFailed)
+	wherePresubmits, presubmitParams := whereThresholdsMet("presubmit_rejects", opts.Thresholds.PresubmitRunsFailed)
+
+	q := c.client.Query(`
+		SELECT
+			STRUCT(cluster_algorithm AS Algorithm, cluster_id as ID) as ClusterID,` +
+		selectCounts("critical_failures_exonerated", "CriticalFailuresExonerated", "1d") +
+		selectCounts("critical_failures_exonerated", "CriticalFailuresExonerated", "3d") +
+		selectCounts("critical_failures_exonerated", "CriticalFailuresExonerated", "7d") +
+		selectCounts("presubmit_rejects", "PresubmitRejects", "1d") +
+		selectCounts("presubmit_rejects", "PresubmitRejects", "3d") +
+		selectCounts("presubmit_rejects", "PresubmitRejects", "7d") +
+		selectCounts("test_run_fails", "TestRunFails", "1d") +
+		selectCounts("test_run_fails", "TestRunFails", "3d") +
+		selectCounts("test_run_fails", "TestRunFails", "7d") +
+		selectCounts("failures", "Failures", "1d") +
+		selectCounts("failures", "Failures", "3d") +
+		selectCounts("failures", "Failures", "7d") + `
+			example_failure_reason.primary_error_message as ExampleFailureReason,
+			top_test_ids as TopTestIDs,
+			ARRAY(
+				SELECT AS STRUCT value, count
+				FROM UNNEST(top_monorail_components)
+				WHERE value IS NOT NULL
+			) as TopMonorailComponents
+		FROM cluster_summaries
+		WHERE (` + whereCriticalFailuresExonerated + `) OR (` + whereFailures + `)
+		    OR (` + whereTestRuns + `) OR (` + wherePresubmits + `)
+		    OR (@alwaysIncludeBugClusters AND cluster_algorithm = @ruleAlgorithmName)
+		ORDER BY
+			presubmit_rejects_residual_1d DESC,
+			critical_failures_exonerated_residual_1d DESC,
+			test_run_fails_residual_1d DESC,
+			failures_residual_1d DESC
+	`)
+	q.DefaultDatasetID = dataset
+
+	params := []bigquery.QueryParameter{
+		{
+			Name:  "ruleAlgorithmName",
+			Value: rulesalgorithm.AlgorithmName,
+		},
+		{
+			Name:  "alwaysIncludeBugClusters",
+			Value: opts.AlwaysIncludeBugClusters,
+		},
+	}
+	params = append(params, cfeParams...)
+	params = append(params, failuresParams...)
+	params = append(params, testRunsParams...)
+	params = append(params, presubmitParams...)
+	q.Parameters = params
+
+	job, err := q.Run(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "querying clusters").Err()
+	}
+	it, err := job.Read(ctx)
+	if err != nil {
+		return nil, handleJobReadError(err)
+	}
+	clusters := []*Cluster{}
+	for {
+		row := &Cluster{}
+		err := it.Next(row)
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return nil, errors.Annotate(err, "obtain next cluster row").Err()
+		}
+		clusters = append(clusters, row)
+	}
+	return clusters, nil
+}
+
+func valueOrDefault(value *int64, defaultValue int64) int64 {
+	if value != nil {
+		return *value
+	}
+	return defaultValue
+}
+
+// selectCounts generates SQL to select a set of Counts.
+func selectCounts(sqlPrefix, fieldPrefix, suffix string) string {
+	return `STRUCT(` +
+		sqlPrefix + `_` + suffix + ` AS Nominal,` +
+		sqlPrefix + `_residual_` + suffix + ` AS Residual` +
+		`) AS ` + fieldPrefix + suffix + `,`
+}
+
+// whereThresholdsMet generates a SQL Where clause to query
+// where a particular metric meets a given threshold.
+func whereThresholdsMet(sqlPrefix string, threshold *configpb.MetricThreshold) (string, []bigquery.QueryParameter) {
+	if threshold == nil {
+		threshold = &configpb.MetricThreshold{}
+	}
+	sql := sqlPrefix + "_residual_1d >= @" + sqlPrefix + "_1d OR " +
+		sqlPrefix + "_residual_3d >= @" + sqlPrefix + "_3d OR " +
+		sqlPrefix + "_residual_7d >= @" + sqlPrefix + "_7d"
+	parameters := []bigquery.QueryParameter{
+		{
+			Name:  sqlPrefix + "_1d",
+			Value: valueOrDefault(threshold.OneDay, math.MaxInt64),
+		},
+		{
+			Name:  sqlPrefix + "_3d",
+			Value: valueOrDefault(threshold.ThreeDay, math.MaxInt64),
+		},
+		{
+			Name:  sqlPrefix + "_7d",
+			Value: valueOrDefault(threshold.SevenDay, math.MaxInt64),
+		},
+	}
+	return sql, parameters
+}
diff --git a/analysis/internal/analysis/conversion.go b/analysis/internal/analysis/conversion.go
new file mode 100644
index 0000000..4075d8a
--- /dev/null
+++ b/analysis/internal/analysis/conversion.go
@@ -0,0 +1,65 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+import (
+	"strings"
+
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// ToBQBuildStatus converts a weetbix.v1.BuildStatus to its BigQuery
+// column representation. This trims the BUILD_STATUS_ prefix to avoid
+// excessive verbosity in the table.
+func ToBQBuildStatus(value pb.BuildStatus) string {
+	return strings.TrimPrefix(value.String(), "BUILD_STATUS_")
+}
+
+// FromBQBuildStatus extracts weetbix.v1.BuildStatus from
+// its BigQuery column representation.
+func FromBQBuildStatus(value string) pb.BuildStatus {
+	return pb.BuildStatus(pb.BuildStatus_value["BUILD_STATUS_"+value])
+}
+
+// ToBQPresubmitRunStatus converts a weetbix.v1.PresubmitRunStatus to its
+// BigQuery column representation. This trims the PRESUBMIT_RUN_STATUS_ prefix
+// to avoid excessive verbosity in the table.
+func ToBQPresubmitRunStatus(value pb.PresubmitRunStatus) string {
+	return strings.TrimPrefix(value.String(), "PRESUBMIT_RUN_STATUS_")
+}
+
+// FromBQPresubmitRunStatus extracts weetbix.v1.PresubmitRunStatus from
+// its BigQuery column representation.
+func FromBQPresubmitRunStatus(value string) pb.PresubmitRunStatus {
+	return pb.PresubmitRunStatus(pb.PresubmitRunStatus_value["PRESUBMIT_RUN_STATUS_"+value])
+}
+
+// ToBQPresubmitRunMode converts a weetbix.v1.PresubmitRunMode to its
+// BigQuery column representation.
+func ToBQPresubmitRunMode(value pb.PresubmitRunMode) string {
+	return value.String()
+}
+
+// FromBQPresubmitRunMode extracts weetbix.v1.PresubmitRunMode from
+// its BigQuery column representation.
+func FromBQPresubmitRunMode(value string) pb.PresubmitRunMode {
+	return pb.PresubmitRunMode(pb.PresubmitRunMode_value[value])
+}
+
+// FromBQExonerationReason extracts weetbix.v1.ExonerationReason from
+// its BigQuery column representation.
+func FromBQExonerationReason(value string) pb.ExonerationReason {
+	return pb.ExonerationReason(pb.ExonerationReason_value[value])
+}
diff --git a/analysis/internal/analysis/queries.go b/analysis/internal/analysis/queries.go
new file mode 100644
index 0000000..0258dc2
--- /dev/null
+++ b/analysis/internal/analysis/queries.go
@@ -0,0 +1,108 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analysis
+
+const clusterAnalysis = `
+  WITH clustered_failures_latest AS (
+	SELECT
+	  cluster_algorithm,
+	  cluster_id,
+	  test_result_system,
+	  test_result_id,
+	  DATE(partition_time) as partition_time,
+	  ARRAY_AGG(cf ORDER BY last_updated DESC LIMIT 1)[OFFSET(0)] as r
+	FROM clustered_failures cf
+	WHERE partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY)
+	GROUP BY cluster_algorithm, cluster_id, test_result_system, test_result_id, DATE(partition_time)
+  ),
+  clustered_failures_extended AS (
+	SELECT
+	  cluster_algorithm,
+	  cluster_id,
+	  r.is_included,
+	  r.is_included_with_high_priority,
+	  r.realm,
+	  COALESCE(ARRAY_LENGTH(r.exonerations) > 0, FALSE) as is_exonerated,
+	  r.build_status = 'FAILURE' as build_failed,
+	  -- Presubmit run and tryjob is critical, and
+	  (r.build_critical AND
+		-- Exonerated for a reason other than NOT_CRITICAL or UNEXPECTED_PASS.
+		-- Passes are not ingested by Weetbix, but if a test has both an unexpected pass
+		-- and an unexpected failure, it will be exonerated for the unexpected pass.
+		(EXISTS
+		  (SELECT TRUE FROM UNNEST(r.exonerations) e
+		  WHERE e.Reason = 'OCCURS_ON_MAINLINE' OR e.Reason = 'OCCURS_ON_OTHER_CLS'))) as is_critical_and_exonerated,
+	  r.test_id,
+	  r.failure_reason,
+	  r.bug_tracking_component,
+	  r.test_run_id,
+	  r.partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 12 HOUR) as is_12h,
+	  r.partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY) as is_1d,
+	  r.partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 3 DAY) as is_3d,
+	  r.partition_time >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 7 DAY) as is_7d,
+	  -- The identity of the first changelist that was tested, assuming the
+	  -- result was part of a presubmit run, and the owner of the presubmit
+	  -- run was a user and not automation.
+	  IF(ARRAY_LENGTH(r.changelists)>0 AND r.presubmit_run_owner='user',
+		  CONCAT(r.changelists[OFFSET(0)].host, r.changelists[OFFSET(0)].change),
+		  NULL) as presubmit_run_user_cl_id,
+	  (r.is_ingested_invocation_blocked AND r.build_critical AND
+		r.presubmit_run_mode = 'FULL_RUN') as is_presubmit_reject,
+	  r.is_test_run_blocked as is_test_run_fail,
+	FROM clustered_failures_latest
+  )
+
+  SELECT
+	  cluster_algorithm,
+	  cluster_id,
+
+	  -- 1 day metrics.
+	  COUNT(DISTINCT IF(is_1d AND is_presubmit_reject AND NOT is_exonerated AND build_failed, presubmit_run_user_cl_id, NULL)) as presubmit_rejects_1d,
+	  COUNT(DISTINCT IF(is_1d AND is_presubmit_reject AND is_included_with_high_priority AND NOT is_exonerated AND build_failed, presubmit_run_user_cl_id, NULL)) as presubmit_rejects_residual_1d,
+	  COUNT(DISTINCT IF(is_1d AND is_test_run_fail, test_run_id, NULL)) as test_run_fails_1d,
+	  COUNT(DISTINCT IF(is_1d AND is_test_run_fail AND is_included_with_high_priority, test_run_id, NULL)) as test_run_fails_residual_1d,
+	  COUNTIF(is_1d) as failures_1d,
+	  COUNTIF(is_1d AND is_included_with_high_priority) as failures_residual_1d,
+	  COUNTIF(is_1d AND is_critical_and_exonerated) as critical_failures_exonerated_1d,
+	  COUNTIF(is_1d AND is_critical_and_exonerated AND is_included_with_high_priority) as critical_failures_exonerated_residual_1d,
+
+	  -- 3 day metrics.
+	  COUNT(DISTINCT IF(is_3d AND is_presubmit_reject AND NOT is_exonerated AND build_failed, presubmit_run_user_cl_id, NULL)) as presubmit_rejects_3d,
+	  COUNT(DISTINCT IF(is_3d AND is_presubmit_reject AND is_included_with_high_priority AND NOT is_exonerated AND build_failed, presubmit_run_user_cl_id, NULL)) as presubmit_rejects_residual_3d,
+	  COUNT(DISTINCT IF(is_3d AND is_test_run_fail, test_run_id, NULL)) as test_run_fails_3d,
+	  COUNT(DISTINCT IF(is_3d AND is_test_run_fail AND is_included_with_high_priority, test_run_id, NULL)) as test_run_fails_residual_3d,
+	  COUNTIF(is_3d) as failures_3d,
+	  COUNTIF(is_3d AND is_included_with_high_priority) as failures_residual_3d,
+	  COUNTIF(is_3d AND is_critical_and_exonerated) as critical_failures_exonerated_3d,
+	  COUNTIF(is_3d AND is_critical_and_exonerated AND is_included_with_high_priority) as critical_failures_exonerated_residual_3d,
+
+	  -- 7 day metrics.
+	  COUNT(DISTINCT IF(is_7d AND is_presubmit_reject AND NOT is_exonerated AND build_failed, presubmit_run_user_cl_id, NULL)) as presubmit_rejects_7d,
+	  COUNT(DISTINCT IF(is_7d AND is_presubmit_reject AND is_included_with_high_priority AND NOT is_exonerated AND build_failed, presubmit_run_user_cl_id, NULL)) as presubmit_rejects_residual_7d,
+	  COUNT(DISTINCT IF(is_7d AND is_test_run_fail, test_run_id, NULL)) as test_run_fails_7d,
+	  COUNT(DISTINCT IF(is_7d AND is_test_run_fail AND is_included_with_high_priority, test_run_id, NULL)) as test_run_fails_residual_7d,
+	  COUNTIF(is_7d) as failures_7d,
+	  COUNTIF(is_7d AND is_included_with_high_priority) as failures_residual_7d,
+	  COUNTIF(is_7d AND is_critical_and_exonerated) as critical_failures_exonerated_7d,
+	  COUNTIF(is_7d AND is_critical_and_exonerated AND is_included_with_high_priority) as critical_failures_exonerated_residual_7d,
+
+	  -- Other analysis.
+	  ANY_VALUE(failure_reason) as example_failure_reason,
+	  ARRAY_AGG(DISTINCT realm) as realms,
+	  APPROX_TOP_COUNT(test_id, 5) as top_test_ids,
+	  APPROX_TOP_COUNT(IF(bug_tracking_component.system = 'monorail', bug_tracking_component.component, NULL), 5) as top_monorail_components,
+  FROM clustered_failures_extended
+  WHERE is_included
+  GROUP BY cluster_algorithm, cluster_id`
diff --git a/analysis/internal/analyzedtestvariants/main_test.go b/analysis/internal/analyzedtestvariants/main_test.go
new file mode 100644
index 0000000..4c972a9
--- /dev/null
+++ b/analysis/internal/analyzedtestvariants/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analyzedtestvariants
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/analyzedtestvariants/purge.go b/analysis/internal/analyzedtestvariants/purge.go
new file mode 100644
index 0000000..0aa9a0f
--- /dev/null
+++ b/analysis/internal/analyzedtestvariants/purge.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analyzedtestvariants
+
+import (
+	"context"
+
+	"cloud.google.com/go/spanner"
+
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/server/span"
+
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+)
+
+func purge(ctx context.Context) (int64, error) {
+	st := spanner.NewStatement(`
+		DELETE FROM AnalyzedTestVariants
+		WHERE Status in UNNEST(@statuses)
+		AND StatusUpdateTime < TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 31 DAY)
+	`)
+	st.Params = map[string]interface{}{
+		"statuses": []int{int(atvpb.Status_NO_NEW_RESULTS), int(atvpb.Status_CONSISTENTLY_EXPECTED)},
+	}
+	return span.PartitionedUpdate(ctx, st)
+}
+
+// Purge deletes AnalyzedTestVariants rows that have been in NO_NEW_RESULTS or
+// CONSISTENTLY_EXPECTED status for over 1 month.
+//
+// Because Verdicts are interleaved with AnalyzedTestVariants, deleting
+// AnalyzedTestVariants rows also deletes their verdicts.
+func Purge(ctx context.Context) error {
+	purged, err := purge(ctx)
+	logging.Infof(ctx, "Purged %d test variants", purged)
+	return err
+}
diff --git a/analysis/internal/analyzedtestvariants/purge_test.go b/analysis/internal/analyzedtestvariants/purge_test.go
new file mode 100644
index 0000000..0c5e6b5
--- /dev/null
+++ b/analysis/internal/analyzedtestvariants/purge_test.go
@@ -0,0 +1,77 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analyzedtestvariants
+
+import (
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/clock"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestPurge(t *testing.T) {
+	Convey(`TestPurge`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		realm := "chromium:ci"
+		tID1 := "ninja://test1"
+		tID2 := "ninja://test2"
+		tID3 := "ninja://test3"
+		tID4 := "ninja://test4"
+		tID5 := "ninja://test5"
+		vh := "varianthash"
+		now := clock.Now(ctx)
+		ms := []*spanner.Mutation{
+			// Active flaky test variants are not deleted.
+			insert.AnalyzedTestVariant(realm, tID1, vh, atvpb.Status_FLAKY, map[string]interface{}{
+				"StatusUpdateTime": now.Add(-time.Hour),
+			}),
+			// Active flaky test variants are not deleted, even if it has been in the
+			// status for a long time.
+			insert.AnalyzedTestVariant(realm, tID2, vh, atvpb.Status_FLAKY, map[string]interface{}{
+				"StatusUpdateTime": now.Add(-2 * 31 * 24 * time.Hour),
+			}),
+			// No new results, but was newly updated.
+			insert.AnalyzedTestVariant(realm, tID3, vh, atvpb.Status_NO_NEW_RESULTS, map[string]interface{}{
+				"StatusUpdateTime": now.Add(-time.Hour),
+			}),
+			// No new results for over a month, should delete.
+			insert.AnalyzedTestVariant(realm, tID4, vh, atvpb.Status_NO_NEW_RESULTS, map[string]interface{}{
+				"StatusUpdateTime": now.Add(-2 * 31 * 24 * time.Hour),
+			}),
+			// consistently expected for over a month, should delete.
+			insert.AnalyzedTestVariant(realm, tID5, vh, atvpb.Status_CONSISTENTLY_EXPECTED, map[string]interface{}{
+				"StatusUpdateTime": now.Add(-2 * 31 * 24 * time.Hour),
+			}),
+			insert.Verdict(realm, tID1, vh, "build-0", internal.VerdictStatus_EXPECTED, now.Add(-time.Hour), nil),
+			insert.Verdict(realm, tID4, vh, "build-1", internal.VerdictStatus_VERDICT_FLAKY, now.Add(-5*30*24*time.Hour), nil),
+			insert.Verdict(realm, tID4, vh, "build-2", internal.VerdictStatus_EXPECTED, now.Add(-2*30*24*time.Hour), nil),
+			insert.Verdict(realm, tID5, vh, "build-1", internal.VerdictStatus_EXPECTED, now.Add(-2*30*24*time.Hour), nil),
+			insert.Verdict(realm, tID5, vh, "build-2", internal.VerdictStatus_VERDICT_FLAKY, now.Add(-5*24*time.Hour), nil),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		rowCount, err := purge(ctx)
+		So(err, ShouldBeNil)
+		So(rowCount, ShouldEqual, 2)
+	})
+}
diff --git a/analysis/internal/analyzedtestvariants/span.go b/analysis/internal/analyzedtestvariants/span.go
new file mode 100644
index 0000000..95eb04c
--- /dev/null
+++ b/analysis/internal/analyzedtestvariants/span.go
@@ -0,0 +1,114 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analyzedtestvariants
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"cloud.google.com/go/spanner"
+
+	"go.chromium.org/luci/server/span"
+
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+)
+
+// ReadStatusAndTags reads AnalyzedTestVariant rows by keys.
+func ReadStatusAndTags(ctx context.Context, ks spanner.KeySet, f func(*atvpb.AnalyzedTestVariant) error) error {
+	fields := []string{"Realm", "TestId", "VariantHash", "Status", "Tags"}
+	var b spanutil.Buffer
+	return span.Read(ctx, "AnalyzedTestVariants", ks, fields).Do(
+		func(row *spanner.Row) error {
+			tv := &atvpb.AnalyzedTestVariant{}
+			if err := b.FromSpanner(row, &tv.Realm, &tv.TestId, &tv.VariantHash, &tv.Status, &tv.Tags); err != nil {
+				return err
+			}
+			return f(tv)
+		},
+	)
+}
+
+// StatusHistory contains all the information related to a test variant's status changes.
+type StatusHistory struct {
+	Status                    atvpb.Status
+	StatusUpdateTime          time.Time
+	PreviousStatuses          []atvpb.Status
+	PreviousStatusUpdateTimes []time.Time
+}
+
+// ReadStatusHistory reads AnalyzedTestVariant rows by keys and returns the test variant's status related info.
+func ReadStatusHistory(ctx context.Context, k spanner.Key) (*StatusHistory, spanner.NullTime, error) {
+	fields := []string{"Status", "StatusUpdateTime", "NextUpdateTaskEnqueueTime", "PreviousStatuses", "PreviousStatusUpdateTimes"}
+	var b spanutil.Buffer
+	si := &StatusHistory{}
+	var enqTime, t spanner.NullTime
+	err := span.Read(ctx, "AnalyzedTestVariants", spanner.KeySets(k), fields).Do(
+		func(row *spanner.Row) error {
+			if err := b.FromSpanner(row, &si.Status, &t, &enqTime, &si.PreviousStatuses, &si.PreviousStatusUpdateTimes); err != nil {
+				return err
+			}
+			if !t.Valid {
+				return fmt.Errorf("invalid status update time")
+			}
+			si.StatusUpdateTime = t.Time
+			return nil
+		},
+	)
+	return si, enqTime, err
+}
+
+// ReadNextUpdateTaskEnqueueTime reads the NextUpdateTaskEnqueueTime from the
+// requested test variant.
+func ReadNextUpdateTaskEnqueueTime(ctx context.Context, k spanner.Key) (spanner.NullTime, error) {
+	row, err := span.ReadRow(ctx, "AnalyzedTestVariants", k, []string{"NextUpdateTaskEnqueueTime"})
+	if err != nil {
+		return spanner.NullTime{}, err
+	}
+
+	var t spanner.NullTime
+	err = row.Column(0, &t)
+	return t, err
+}
+
+// QueryTestVariantsByBuilder queries AnalyzedTestVariants with unexpected
+// results on the given builder.
+func QueryTestVariantsByBuilder(ctx context.Context, realm, builder string, f func(*atvpb.AnalyzedTestVariant) error) error {
+	st := spanner.NewStatement(`
+		SELECT TestId, VariantHash
+		FROM AnalyzedTestVariants@{FORCE_INDEX=AnalyzedTestVariantsByBuilderAndStatus, spanner_emulator.disable_query_null_filtered_index_check=true}
+		WHERE Realm = @realm
+		AND Builder = @builder
+		AND Status in UNNEST(@statuses)
+		ORDER BY TestId, VariantHash
+	`)
+	st.Params = map[string]interface{}{
+		"realm":    realm,
+		"builder":  builder,
+		"statuses": []int{int(atvpb.Status_FLAKY), int(atvpb.Status_CONSISTENTLY_UNEXPECTED), int(atvpb.Status_HAS_UNEXPECTED_RESULTS)},
+	}
+
+	var b spanutil.Buffer
+	return span.Query(ctx, st).Do(
+		func(row *spanner.Row) error {
+			tv := &atvpb.AnalyzedTestVariant{}
+			if err := b.FromSpanner(row, &tv.TestId, &tv.VariantHash); err != nil {
+				return err
+			}
+			return f(tv)
+		},
+	)
+}
diff --git a/analysis/internal/analyzedtestvariants/span_test.go b/analysis/internal/analyzedtestvariants/span_test.go
new file mode 100644
index 0000000..0171bc1
--- /dev/null
+++ b/analysis/internal/analyzedtestvariants/span_test.go
@@ -0,0 +1,115 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package analyzedtestvariants
+
+import (
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestAnalyzedTestVariantSpan(t *testing.T) {
+	Convey(`TestAnalyzedTestVariantSpan`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		realm := "chromium:ci"
+		status := atvpb.Status_FLAKY
+		now := clock.Now(ctx).UTC()
+		ps := []atvpb.Status{
+			atvpb.Status_CONSISTENTLY_EXPECTED,
+			atvpb.Status_FLAKY,
+		}
+		puts := []time.Time{
+			now.Add(-24 * time.Hour),
+			now.Add(-240 * time.Hour),
+		}
+		builder := "builder"
+		ms := []*spanner.Mutation{
+			insert.AnalyzedTestVariant(realm, "ninja://test1", "variantHash1", status,
+				map[string]interface{}{
+					"Builder":                   builder,
+					"StatusUpdateTime":          now.Add(-time.Hour),
+					"PreviousStatuses":          ps,
+					"PreviousStatusUpdateTimes": puts,
+				}),
+			insert.AnalyzedTestVariant(realm, "ninja://test1", "variantHash2", atvpb.Status_HAS_UNEXPECTED_RESULTS, map[string]interface{}{
+				"Builder": builder,
+			}),
+			insert.AnalyzedTestVariant(realm, "ninja://test2", "variantHash1", status,
+				map[string]interface{}{
+					"Builder": "anotherbuilder",
+				}),
+			insert.AnalyzedTestVariant(realm, "ninja://test3", "variantHash", status, nil),
+			insert.AnalyzedTestVariant(realm, "ninja://test4", "variantHash", atvpb.Status_CONSISTENTLY_EXPECTED,
+				map[string]interface{}{
+					"Builder": builder,
+				}),
+			insert.AnalyzedTestVariant("anotherrealm", "ninja://test1", "variantHash1", status,
+				map[string]interface{}{
+					"Builder": builder,
+				}),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		Convey(`TestReadStatus`, func() {
+			ks := spanner.KeySets(
+				spanner.Key{realm, "ninja://test1", "variantHash1"},
+				spanner.Key{realm, "ninja://test1", "variantHash2"},
+				spanner.Key{realm, "ninja://test-not-exists", "variantHash1"},
+			)
+			atvs := make([]*atvpb.AnalyzedTestVariant, 0)
+			err := ReadStatusAndTags(span.Single(ctx), ks, func(atv *atvpb.AnalyzedTestVariant) error {
+				So(atv.Realm, ShouldEqual, realm)
+				atvs = append(atvs, atv)
+				return nil
+			})
+			So(err, ShouldBeNil)
+			So(len(atvs), ShouldEqual, 2)
+		})
+
+		Convey(`TestReadStatusHistory`, func() {
+			exp := &StatusHistory{
+				Status:                    status,
+				StatusUpdateTime:          now.Add(-time.Hour),
+				PreviousStatuses:          ps,
+				PreviousStatusUpdateTimes: puts,
+			}
+
+			si, enqTime, err := ReadStatusHistory(span.Single(ctx), spanner.Key{realm, "ninja://test1", "variantHash1"})
+			So(err, ShouldBeNil)
+			So(si, ShouldResemble, exp)
+			So(enqTime, ShouldResemble, spanner.NullTime{})
+		})
+
+		Convey(`TestQueryTestVariantsByBuilder`, func() {
+			atvs := make([]*atvpb.AnalyzedTestVariant, 0)
+			err := QueryTestVariantsByBuilder(span.Single(ctx), realm, builder, func(atv *atvpb.AnalyzedTestVariant) error {
+				atvs = append(atvs, atv)
+				return nil
+			})
+			So(err, ShouldBeNil)
+			So(len(atvs), ShouldEqual, 2)
+		})
+	})
+}
diff --git a/analysis/internal/bqutil/dataset.go b/analysis/internal/bqutil/dataset.go
new file mode 100644
index 0000000..fa9a530
--- /dev/null
+++ b/analysis/internal/bqutil/dataset.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqutil
+
+import (
+	"strings"
+
+	"go.chromium.org/luci/analysis/internal/config"
+
+	"go.chromium.org/luci/common/errors"
+)
+
+// DatasetForProject returns the name of the BigQuery dataset that contains
+// the given project's data, in the Weetbix GCP project.
+func DatasetForProject(luciProject string) (string, error) {
+	// The returned dataset may be used in SQL expressions, so we want to
+	// be absolutely sure no SQL Injection is possible.
+	if !config.ProjectRe.MatchString(luciProject) {
+		return "", errors.New("invalid LUCI Project")
+	}
+
+	// The valid alphabet of LUCI project names [1] is [a-z0-9-] whereas
+	// the valid alphabet of BQ dataset names [2] is [a-zA-Z0-9_].
+	// [1]: https://source.chromium.org/chromium/infra/infra/+/main:luci/appengine/components/components/config/common.py?q=PROJECT_ID_PATTERN
+	// [2]: https://cloud.google.com/bigquery/docs/datasets#dataset-naming
+	return strings.ReplaceAll(luciProject, "-", "_"), nil
+}
+
+// ProjectForDataset returns the name of the LUCI Project that corresponds
+// to the given BigQuery dataset.
+func ProjectForDataset(dataset string) (string, error) {
+	project := strings.ReplaceAll(dataset, "_", "-")
+
+	if !config.ProjectRe.MatchString(project) {
+		return "", errors.New("invalid LUCI Project")
+	}
+
+	return project, nil
+}
diff --git a/analysis/internal/bqutil/insert.go b/analysis/internal/bqutil/insert.go
new file mode 100644
index 0000000..70c5983
--- /dev/null
+++ b/analysis/internal/bqutil/insert.go
@@ -0,0 +1,122 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqutil
+
+import (
+	"context"
+	"net/http"
+
+	"cloud.google.com/go/bigquery"
+	"google.golang.org/api/googleapi"
+	"google.golang.org/api/option"
+
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/retry"
+	"go.chromium.org/luci/common/retry/transient"
+	"go.chromium.org/luci/server/auth"
+)
+
+// Client returns a new BigQuery client for use with the given GCP project,
+// that authenticates as Weetbix itself. Only use this method if the
+// specification of the BigQuery dataset to access is not under the
+// control of the project (e.g. via configuration).
+func Client(ctx context.Context, gcpProject string) (*bigquery.Client, error) {
+	if gcpProject == "" {
+		return nil, errors.New("GCP Project must be specified")
+	}
+	tr, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithScopes(bigquery.Scope))
+	if err != nil {
+		return nil, err
+	}
+	return bigquery.NewClient(ctx, gcpProject, option.WithHTTPClient(&http.Client{
+		Transport: tr,
+	}))
+}
+
+// Inserter provides methods to insert rows into a BigQuery table.
+type Inserter struct {
+	table     *bigquery.Table
+	batchSize int
+}
+
+// NewInserter initialises a new inserter.
+func NewInserter(table *bigquery.Table, batchSize int) *Inserter {
+	return &Inserter{
+		table:     table,
+		batchSize: batchSize,
+	}
+}
+
+// Put inserts the given rows into BigQuery.
+func (i *Inserter) Put(ctx context.Context, rows []*bq.Row) error {
+	inserter := i.table.Inserter()
+	for i, batch := range i.batch(rows) {
+		if err := inserter.Put(ctx, batch); err != nil {
+			return errors.Annotate(err, "putting batch %v", i).Err()
+		}
+	}
+	return nil
+}
+
+// batch divides the rows to be inserted into batches of at most batchSize.
+func (i *Inserter) batch(rows []*bq.Row) [][]*bq.Row {
+	var result [][]*bq.Row
+	pages := (len(rows) + (i.batchSize - 1)) / i.batchSize
+	for p := 0; p < pages; p++ {
+		start := p * i.batchSize
+		end := start + i.batchSize
+		if end > len(rows) {
+			end = len(rows)
+		}
+		page := rows[start:end]
+		result = append(result, page)
+	}
+	return result
+}
+
+func hasReason(apiErr *googleapi.Error, reason string) bool {
+	for _, e := range apiErr.Errors {
+		if e.Reason == reason {
+			return true
+		}
+	}
+	return false
+}
+
+// PutWithRetries puts rows into BigQuery.
+// Retries on transient errors.
+func (i *Inserter) PutWithRetries(ctx context.Context, rows []*bq.Row) error {
+	return retry.Retry(ctx, transient.Only(retry.Default), func() error {
+		err := i.Put(ctx, rows)
+
+		switch e := err.(type) {
+		case *googleapi.Error:
+			if e.Code == http.StatusForbidden && hasReason(e, "quotaExceeded") {
+				err = transient.Tag.Apply(err)
+			}
+		}
+
+		return err
+	}, retry.LogCallback(ctx, "bigquery_put"))
+}
+
+// FatalError returns true if the error is a known fatal error.
+func FatalError(err error) bool {
+	if apiErr, ok := err.(*googleapi.Error); ok && apiErr.Code == http.StatusForbidden && hasReason(apiErr, "accessDenied") {
+		return true
+	}
+	return false
+}
diff --git a/analysis/internal/bqutil/schema.go b/analysis/internal/bqutil/schema.go
new file mode 100644
index 0000000..7248e99
--- /dev/null
+++ b/analysis/internal/bqutil/schema.go
@@ -0,0 +1,42 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bqutil
+
+import (
+	"cloud.google.com/go/bigquery"
+
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/proto/google/descutil"
+
+	desc "github.com/golang/protobuf/protoc-gen-go/descriptor"
+)
+
+// GenerateSchema generates BigQuery schema for the given proto message
+// using the given set of message definitions.
+func GenerateSchema(fdset *desc.FileDescriptorSet, message string) (schema bigquery.Schema, err error) {
+	conv := bq.SchemaConverter{
+		Desc:           fdset,
+		SourceCodeInfo: make(map[*desc.FileDescriptorProto]bq.SourceCodeInfoMap, len(fdset.File)),
+	}
+	for _, f := range fdset.File {
+		conv.SourceCodeInfo[f], err = descutil.IndexSourceCodeInfo(f)
+		if err != nil {
+			return nil, errors.Annotate(err, "failed to index source code info in file %q", f.GetName()).Err()
+		}
+	}
+	schema, _, err = conv.Schema(message)
+	return schema, err
+}
diff --git a/analysis/internal/bugs/bugid.go b/analysis/internal/bugs/bugid.go
new file mode 100644
index 0000000..d658939
--- /dev/null
+++ b/analysis/internal/bugs/bugid.go
@@ -0,0 +1,81 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bugs
+
+import (
+	"errors"
+	"fmt"
+	"regexp"
+)
+
+// MonorailSystem is the name of the monorail bug tracker system.
+const MonorailSystem = "monorail"
+
+// BuganizerSystem is the name of the buganizer bug tracker system.
+const BuganizerSystem = "buganizer"
+
+// MonorailBugIDRe matches identifiers of monorail bugs, like
+// "{monorail_project}/{numeric_id}".
+var MonorailBugIDRe = regexp.MustCompile(`^([a-z0-9\-_]+)/([1-9][0-9]*)$`)
+
+// BuganizerBugIDRe matches identifiers of buganizer bugs (excluding
+// the b/), like 1234567890.
+var BuganizerBugIDRe = regexp.MustCompile(`^([1-9][0-9]*)$`)
+
+// BugID represents the identity of a bug managed by Weetbix.
+type BugID struct {
+	// System is the bug tracking system of the bug. This is either
+	// "monorail" or "buganizer".
+	System string `json:"system"`
+	// ID is the bug tracking system-specific identity of the bug.
+	// For monorail, the scheme is {project}/{numeric_id}, for
+	// buganizer the scheme is {numeric_id}.
+	ID string `json:"id"`
+}
+
+// Validate checks if BugID is a valid bug reference. If not, it
+// returns an error.
+func (b *BugID) Validate() error {
+	switch b.System {
+	case MonorailSystem:
+		if !MonorailBugIDRe.MatchString(b.ID) {
+			return fmt.Errorf("invalid monorail bug ID %q", b.ID)
+		}
+	case BuganizerSystem:
+		if !BuganizerBugIDRe.MatchString(b.ID) {
+			return fmt.Errorf("invalid buganizer bug ID %q", b.ID)
+		}
+	default:
+		return fmt.Errorf("invalid bug tracking system %q", b.System)
+	}
+	return nil
+}
+
+// MonorailID returns the monorail project and ID of the given bug.
+// If the bug is not a monorail bug or is invalid, an error is returned.
+func (b *BugID) MonorailProjectAndID() (project, id string, err error) {
+	if b.System != MonorailSystem {
+		return "", "", errors.New("not a monorail bug")
+	}
+	m := MonorailBugIDRe.FindStringSubmatch(b.ID)
+	if m == nil {
+		return "", "", errors.New("not a valid monorail bug ID")
+	}
+	return m[1], m[2], nil
+}
+
+func (b BugID) String() string {
+	return fmt.Sprintf("%s:%s", b.System, b.ID)
+}
diff --git a/analysis/internal/bugs/bugid_test.go b/analysis/internal/bugs/bugid_test.go
new file mode 100644
index 0000000..42571cb
--- /dev/null
+++ b/analysis/internal/bugs/bugid_test.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bugs
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestValidate(t *testing.T) {
+	Convey(`Validate`, t, func() {
+		id := BugID{
+			System: "monorail",
+			ID:     "chromium/123",
+		}
+		Convey(`System missing`, func() {
+			id.System = ""
+			err := id.Validate()
+			So(err, ShouldErrLike, `invalid bug tracking system`)
+		})
+		Convey("ID invalid", func() {
+			id.ID = "!!!"
+			err := id.Validate()
+			So(err, ShouldErrLike, `invalid monorail bug ID`)
+		})
+	})
+}
diff --git a/analysis/internal/bugs/interface.go b/analysis/internal/bugs/interface.go
new file mode 100644
index 0000000..d494a90
--- /dev/null
+++ b/analysis/internal/bugs/interface.go
@@ -0,0 +1,80 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bugs
+
+import (
+	"errors"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+)
+
+type BugUpdateRequest struct {
+	// The bug to update.
+	Bug BugID
+	// Impact for the given bug. This is only set if valid impact is available,
+	// if re-clustering is currently ongoing for the failure association rule
+	// and impact is unreliable, this will be unset to avoid erroneous
+	// priority updates.
+	Impact *ClusterImpact
+	// Whether the user enabled priority updates and auto-closure for the bug.
+	// If this if false, the BugUpdateRequest is only made to determine if the
+	// bug is the duplicate of another bug and if the rule should be archived.
+	IsManagingBug bool
+	// The identity of the rule associated with the bug.
+	RuleID string
+}
+
+type BugUpdateResponse struct {
+	// IsDuplicate is set if the bug is a duplicate of another.
+	IsDuplicate bool
+
+	// ShouldArchive indicates if the rule for this bug should be archived.
+	// This should be set if:
+	// - The bug is managed by Weetbix (IsManagingBug = true) and it has
+	//   been marked as Closed (verified) by Weetbix for the last 30 days.
+	// - The bug is managed by the user (IsManagingBug = false), and the
+	//   bug has been closed for the last 30 days.
+	ShouldArchive bool
+}
+
+var ErrCreateSimulated = errors.New("CreateNew did not create a bug as the bug manager is in simulation mode")
+
+// CreateRequest captures key details of a cluster and its impact,
+// as needed for filing new bugs.
+type CreateRequest struct {
+	// Description is the human-readable description of the cluster.
+	Description *clustering.ClusterDescription
+	// Impact describes the impact of cluster.
+	Impact *ClusterImpact
+	// The monorail components (if any) to use.
+	MonorailComponents []string
+}
+
+// ClusterImpact captures details of a cluster's impact, as needed
+// to control the priority and verified status of bugs.
+type ClusterImpact struct {
+	CriticalFailuresExonerated MetricImpact
+	TestResultsFailed          MetricImpact
+	TestRunsFailed             MetricImpact
+	PresubmitRunsFailed        MetricImpact
+}
+
+// MetricImpact captures impact measurements for one metric, over
+// different timescales.
+type MetricImpact struct {
+	OneDay   int64
+	ThreeDay int64
+	SevenDay int64
+}
diff --git a/analysis/internal/bugs/metrics.go b/analysis/internal/bugs/metrics.go
new file mode 100644
index 0000000..a50b86b
--- /dev/null
+++ b/analysis/internal/bugs/metrics.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bugs
+
+import (
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/common/tsmon/types"
+)
+
+var (
+	// BugsCreatedCounter is the metric that counts the number of bugs
+	// created by Weetbix, by project and bug-filing system.
+	BugsCreatedCounter = metric.NewCounter("weetbix/bug_updater/bugs_created",
+		"The number of bugs created by auto-bug filing, "+
+			"by LUCI Project and bug-filing system.",
+		&types.MetricMetadata{
+			Units: "bugs",
+		},
+		// The LUCI project.
+		field.String("project"),
+		// The bug-filing system. Either "monorail" or "buganizer".
+		field.String("bug_system"),
+	)
+
+	BugsUpdatedCounter = metric.NewCounter("weetbix/bug_updater/bugs_updated",
+		"The number of bugs updated by auto-bug filing, "+
+			"by LUCI Project and bug-filing system.",
+		&types.MetricMetadata{
+			Units: "bugs",
+		},
+		// The LUCI project.
+		field.String("project"),
+		// The bug-filing system. Either "monorail" or "buganizer".
+		field.String("bug_system"))
+)
diff --git a/analysis/internal/bugs/monorail/api_proto/copy.sh b/analysis/internal/bugs/monorail/api_proto/copy.sh
new file mode 100755
index 0000000..97d4eb8
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/copy.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/sh
+# Copyright 2022 The LUCI Authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Delete existing generated files.
+rm *.proto
+rm *.pb.go
+
+# Copy proto files from monorail directory.
+cp ../../../../../../../../../appengine/monorail/api/v3/api_proto/*.proto .
+
+# Replace go_package.
+sed -i 's/infra\/monorailv2\/api\/v3/go.chromium.org\/luci\/analysis\/internal\/bugs\/monorail/g' *.proto
+
+# Fixup imports to other files.
+sed -i 's/api\/v3/go.chromium.org\/luci\/analysis\/internal\/bugs\/monorail/g' *.proto
diff --git a/analysis/internal/bugs/monorail/api_proto/feature_objects.pb.go b/analysis/internal/bugs/monorail/api_proto/feature_objects.pb.go
new file mode 100644
index 0000000..6a81c5f
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/feature_objects.pb.go
@@ -0,0 +1,464 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for features and related business
+// objects, e.g., hotlists.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/feature_objects.proto
+
+package api_proto
+
+import (
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Privacy level of a Hotlist.
+// Next available tag: 2
+type Hotlist_HotlistPrivacy int32
+
+const (
+	// This value is unused.
+	Hotlist_HOTLIST_PRIVACY_UNSPECIFIED Hotlist_HotlistPrivacy = 0
+	// Only the owner and editors of the hotlist can view the hotlist.
+	Hotlist_PRIVATE Hotlist_HotlistPrivacy = 1
+	// Anyone on the web can view the hotlist.
+	Hotlist_PUBLIC Hotlist_HotlistPrivacy = 2
+)
+
+// Enum value maps for Hotlist_HotlistPrivacy.
+var (
+	Hotlist_HotlistPrivacy_name = map[int32]string{
+		0: "HOTLIST_PRIVACY_UNSPECIFIED",
+		1: "PRIVATE",
+		2: "PUBLIC",
+	}
+	Hotlist_HotlistPrivacy_value = map[string]int32{
+		"HOTLIST_PRIVACY_UNSPECIFIED": 0,
+		"PRIVATE":                     1,
+		"PUBLIC":                      2,
+	}
+)
+
+func (x Hotlist_HotlistPrivacy) Enum() *Hotlist_HotlistPrivacy {
+	p := new(Hotlist_HotlistPrivacy)
+	*p = x
+	return p
+}
+
+func (x Hotlist_HotlistPrivacy) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Hotlist_HotlistPrivacy) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_enumTypes[0].Descriptor()
+}
+
+func (Hotlist_HotlistPrivacy) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_enumTypes[0]
+}
+
+func (x Hotlist_HotlistPrivacy) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Hotlist_HotlistPrivacy.Descriptor instead.
+func (Hotlist_HotlistPrivacy) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescGZIP(), []int{0, 0}
+}
+
+// A user-owned list of Issues.
+// Next available tag: 9
+type Hotlist struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the hotlist.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// `display_name` must follow pattern found at `framework_bizobj.RE_HOTLIST_NAME_PATTERN`.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Resource name of the hotlist owner.
+	// Owners can update hotlist settings, editors, owner, and HotlistItems.
+	// TODO(monorail:7023): field_behavior may be changed in the future.
+	Owner string `protobuf:"bytes,3,opt,name=owner,proto3" json:"owner,omitempty"`
+	// Resource names of the hotlist editors.
+	// Editors can update hotlist HotlistItems.
+	Editors []string `protobuf:"bytes,4,rep,name=editors,proto3" json:"editors,omitempty"`
+	// Summary of the hotlist.
+	Summary string `protobuf:"bytes,5,opt,name=summary,proto3" json:"summary,omitempty"`
+	// More detailed description of the purpose of the hotlist.
+	Description string `protobuf:"bytes,6,opt,name=description,proto3" json:"description,omitempty"`
+	// Ordered list of default columns shown on hotlist's issues list view.
+	DefaultColumns []*IssuesListColumn    `protobuf:"bytes,7,rep,name=default_columns,json=defaultColumns,proto3" json:"default_columns,omitempty"`
+	HotlistPrivacy Hotlist_HotlistPrivacy `protobuf:"varint,8,opt,name=hotlist_privacy,json=hotlistPrivacy,proto3,enum=monorail.v3.Hotlist_HotlistPrivacy" json:"hotlist_privacy,omitempty"`
+}
+
+func (x *Hotlist) Reset() {
+	*x = Hotlist{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Hotlist) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Hotlist) ProtoMessage() {}
+
+func (x *Hotlist) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Hotlist.ProtoReflect.Descriptor instead.
+func (*Hotlist) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Hotlist) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Hotlist) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *Hotlist) GetOwner() string {
+	if x != nil {
+		return x.Owner
+	}
+	return ""
+}
+
+func (x *Hotlist) GetEditors() []string {
+	if x != nil {
+		return x.Editors
+	}
+	return nil
+}
+
+func (x *Hotlist) GetSummary() string {
+	if x != nil {
+		return x.Summary
+	}
+	return ""
+}
+
+func (x *Hotlist) GetDescription() string {
+	if x != nil {
+		return x.Description
+	}
+	return ""
+}
+
+func (x *Hotlist) GetDefaultColumns() []*IssuesListColumn {
+	if x != nil {
+		return x.DefaultColumns
+	}
+	return nil
+}
+
+func (x *Hotlist) GetHotlistPrivacy() Hotlist_HotlistPrivacy {
+	if x != nil {
+		return x.HotlistPrivacy
+	}
+	return Hotlist_HOTLIST_PRIVACY_UNSPECIFIED
+}
+
+// Represents the the position of an Issue in a Hotlist.
+// Next available tag: 7
+type HotlistItem struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the HotlistItem.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The Issue associated with this item.
+	Issue string `protobuf:"bytes,2,opt,name=issue,proto3" json:"issue,omitempty"`
+	// Represents the item's position in the Hotlist in decreasing priority order.
+	// Values will be from 1 to N (the size of the hotlist), each item having a unique rank.
+	// Changes to rank must be made in `RerankHotlistItems`.
+	Rank uint32 `protobuf:"varint,3,opt,name=rank,proto3" json:"rank,omitempty"`
+	// Resource name of the adder of HotlistItem.
+	Adder string `protobuf:"bytes,4,opt,name=adder,proto3" json:"adder,omitempty"`
+	// The time this HotlistItem was added to the hotlist.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+	// User-provided additional details about this item.
+	Note string `protobuf:"bytes,6,opt,name=note,proto3" json:"note,omitempty"`
+}
+
+func (x *HotlistItem) Reset() {
+	*x = HotlistItem{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *HotlistItem) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HotlistItem) ProtoMessage() {}
+
+func (x *HotlistItem) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use HotlistItem.ProtoReflect.Descriptor instead.
+func (*HotlistItem) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *HotlistItem) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *HotlistItem) GetIssue() string {
+	if x != nil {
+		return x.Issue
+	}
+	return ""
+}
+
+func (x *HotlistItem) GetRank() uint32 {
+	if x != nil {
+		return x.Rank
+	}
+	return 0
+}
+
+func (x *HotlistItem) GetAdder() string {
+	if x != nil {
+		return x.Adder
+	}
+	return ""
+}
+
+func (x *HotlistItem) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+func (x *HotlistItem) GetNote() string {
+	if x != nil {
+		return x.Note
+	}
+	return ""
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDesc = []byte{
+	0x0a, 0x54, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x66, 0x65, 0x61, 0x74, 0x75, 0x72, 0x65, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
+	0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69,
+	0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
+	0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+	0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x1a, 0x52, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x22, 0x85, 0x04, 0x0a, 0x07, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74,
+	0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52,
+	0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x30, 0x0a, 0x05,
+	0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14,
+	0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x02, 0x52, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x31,
+	0x0a, 0x07, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x42,
+	0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x07, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72,
+	0x73, 0x12, 0x1d, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x18, 0x05, 0x20, 0x01,
+	0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79,
+	0x12, 0x25, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18,
+	0x06, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63,
+	0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x46, 0x0a, 0x0f, 0x64, 0x65, 0x66, 0x61, 0x75,
+	0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x1d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x73, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52,
+	0x0e, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12,
+	0x4c, 0x0a, 0x0f, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x70, 0x72, 0x69, 0x76, 0x61,
+	0x63, 0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x2e, 0x48,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x52, 0x0e, 0x68,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x22, 0x4a, 0x0a,
+	0x0e, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x12,
+	0x1f, 0x0a, 0x1b, 0x48, 0x4f, 0x54, 0x4c, 0x49, 0x53, 0x54, 0x5f, 0x50, 0x52, 0x49, 0x56, 0x41,
+	0x43, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
+	0x12, 0x0b, 0x0a, 0x07, 0x50, 0x52, 0x49, 0x56, 0x41, 0x54, 0x45, 0x10, 0x01, 0x12, 0x0a, 0x0a,
+	0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x3a, 0x31, 0xea, 0x41, 0x2e, 0x0a, 0x15,
+	0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f,
+	0x74, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x15, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x2f,
+	0x7b, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x7d, 0x22, 0xbc, 0x02, 0x0a,
+	0x0b, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x12, 0x31, 0x0a, 0x05, 0x69, 0x73, 0x73, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x1b, 0xfa, 0x41, 0x15, 0x0a, 0x13, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75, 0x65, 0xe0, 0x41, 0x05, 0x52, 0x05, 0x69, 0x73,
+	0x73, 0x75, 0x65, 0x12, 0x17, 0x0a, 0x04, 0x72, 0x61, 0x6e, 0x6b, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x0d, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x04, 0x72, 0x61, 0x6e, 0x6b, 0x12, 0x30, 0x0a, 0x05,
+	0x61, 0x64, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14,
+	0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x03, 0x52, 0x05, 0x61, 0x64, 0x64, 0x65, 0x72, 0x12, 0x40,
+	0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42,
+	0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65,
+	0x12, 0x12, 0x0a, 0x04, 0x6e, 0x6f, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x6e, 0x6f, 0x74, 0x65, 0x3a, 0x45, 0xea, 0x41, 0x42, 0x0a, 0x19, 0x61, 0x70, 0x69, 0x2e, 0x63,
+	0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74,
+	0x49, 0x74, 0x65, 0x6d, 0x12, 0x25, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x2f, 0x7b,
+	0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x7d, 0x2f, 0x69, 0x74, 0x65, 0x6d,
+	0x73, 0x2f, 0x7b, 0x69, 0x74, 0x65, 0x6d, 0x5f, 0x69, 0x64, 0x7d, 0x42, 0x40, 0x5a, 0x3e, 0x67,
+	0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c,
+	0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74,
+	0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_goTypes = []interface{}{
+	(Hotlist_HotlistPrivacy)(0),   // 0: monorail.v3.Hotlist.HotlistPrivacy
+	(*Hotlist)(nil),               // 1: monorail.v3.Hotlist
+	(*HotlistItem)(nil),           // 2: monorail.v3.HotlistItem
+	(*IssuesListColumn)(nil),      // 3: monorail.v3.IssuesListColumn
+	(*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_depIdxs = []int32{
+	3, // 0: monorail.v3.Hotlist.default_columns:type_name -> monorail.v3.IssuesListColumn
+	0, // 1: monorail.v3.Hotlist.hotlist_privacy:type_name -> monorail.v3.Hotlist.HotlistPrivacy
+	4, // 2: monorail.v3.HotlistItem.create_time:type_name -> google.protobuf.Timestamp
+	3, // [3:3] is the sub-list for method output_type
+	3, // [3:3] is the sub-list for method input_type
+	3, // [3:3] is the sub-list for extension type_name
+	3, // [3:3] is the sub-list for extension extendee
+	0, // [0:3] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Hotlist); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*HotlistItem); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDesc,
+			NumEnums:      1,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_depIdxs,
+		EnumInfos:         file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_enumTypes,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_depIdxs = nil
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/feature_objects.proto b/analysis/internal/bugs/monorail/api_proto/feature_objects.proto
new file mode 100644
index 0000000..a5374fa
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/feature_objects.proto
@@ -0,0 +1,88 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for features and related business
+// objects, e.g., hotlists.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/issue_objects.proto";
+
+// A user-owned list of Issues.
+// Next available tag: 9
+message Hotlist {
+  option (google.api.resource) = {
+    type: "api.crbug.com/Hotlist"
+    pattern: "hotlists/{hotlist_id}"
+  };
+
+  // Resource name of the hotlist.
+  string name = 1;
+  // `display_name` must follow pattern found at `framework_bizobj.RE_HOTLIST_NAME_PATTERN`.
+  string display_name = 2 [ (google.api.field_behavior) = REQUIRED ];
+  // Resource name of the hotlist owner.
+  // Owners can update hotlist settings, editors, owner, and HotlistItems.
+  // TODO(monorail:7023): field_behavior may be changed in the future.
+  string owner = 3 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"},
+      (google.api.field_behavior) = REQUIRED ];
+  // Resource names of the hotlist editors.
+  // Editors can update hotlist HotlistItems.
+  repeated string editors = 4 [ (google.api.resource_reference) = {type: "api.crbug.com/User"} ];
+  // Summary of the hotlist.
+  string summary = 5 [ (google.api.field_behavior) = REQUIRED ];
+  // More detailed description of the purpose of the hotlist.
+  string description = 6 [ (google.api.field_behavior) = REQUIRED ];
+  // Ordered list of default columns shown on hotlist's issues list view.
+  repeated IssuesListColumn default_columns = 7;
+
+  // Privacy level of a Hotlist.
+  // Next available tag: 2
+  enum HotlistPrivacy {
+    // This value is unused.
+    HOTLIST_PRIVACY_UNSPECIFIED = 0;
+    // Only the owner and editors of the hotlist can view the hotlist.
+    PRIVATE = 1;
+    // Anyone on the web can view the hotlist.
+    PUBLIC = 2;
+  }
+  HotlistPrivacy hotlist_privacy = 8;
+}
+
+
+// Represents the the position of an Issue in a Hotlist.
+// Next available tag: 7
+message HotlistItem {
+  option (google.api.resource) = {
+    type: "api.crbug.com/HotlistItem"
+    pattern: "hotlists/{hotlist_id}/items/{item_id}"
+  };
+
+  // Resource name of the HotlistItem.
+  string name = 1;
+  // The Issue associated with this item.
+  string issue = 2 [
+      (google.api.resource_reference) = {type: "api.crbug.com/Issue"},
+      (google.api.field_behavior) = IMMUTABLE ];
+  // Represents the item's position in the Hotlist in decreasing priority order.
+  // Values will be from 1 to N (the size of the hotlist), each item having a unique rank.
+  // Changes to rank must be made in `RerankHotlistItems`.
+  uint32 rank = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // Resource name of the adder of HotlistItem.
+  string adder = 4 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"},
+      (google.api.field_behavior) = OUTPUT_ONLY ];
+  // The time this HotlistItem was added to the hotlist.
+  google.protobuf.Timestamp create_time = 5  [ (google.api.field_behavior) = OUTPUT_ONLY ];
+  // User-provided additional details about this item.
+  string note = 6;
+}
\ No newline at end of file
diff --git a/analysis/internal/bugs/monorail/api_proto/frontend.pb.go b/analysis/internal/bugs/monorail/api_proto/frontend.pb.go
new file mode 100644
index 0000000..43312af
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/frontend.pb.go
@@ -0,0 +1,684 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/frontend.proto
+
+package api_proto
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Request message for GatherProjectEnvironment
+// Next available tag: 2
+type GatherProjectEnvironmentRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the project these config environments belong to.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+}
+
+func (x *GatherProjectEnvironmentRequest) Reset() {
+	*x = GatherProjectEnvironmentRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GatherProjectEnvironmentRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatherProjectEnvironmentRequest) ProtoMessage() {}
+
+func (x *GatherProjectEnvironmentRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatherProjectEnvironmentRequest.ProtoReflect.Descriptor instead.
+func (*GatherProjectEnvironmentRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GatherProjectEnvironmentRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+// Response message for GatherProjectEnvironment
+// Next available tag: 9
+type GatherProjectEnvironmentResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Project definitions such as display_name and summary.
+	Project *Project `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// Configurations of this project such as default search term,
+	// default templates for members and non members.
+	ProjectConfig *ProjectConfig `protobuf:"bytes,2,opt,name=project_config,json=projectConfig,proto3" json:"project_config,omitempty"`
+	// List of statuses that belong to this project.
+	Statuses []*StatusDef `protobuf:"bytes,3,rep,name=statuses,proto3" json:"statuses,omitempty"`
+	// List of well known labels that belong to this project.
+	WellKnownLabels []*LabelDef `protobuf:"bytes,4,rep,name=well_known_labels,json=wellKnownLabels,proto3" json:"well_known_labels,omitempty"`
+	// List of components that belong to this project.
+	Components []*ComponentDef `protobuf:"bytes,5,rep,name=components,proto3" json:"components,omitempty"`
+	// List of custom fields that belong to this project.
+	Fields []*FieldDef `protobuf:"bytes,6,rep,name=fields,proto3" json:"fields,omitempty"`
+	// List of approval fields that belong to this project.
+	ApprovalFields []*ApprovalDef `protobuf:"bytes,7,rep,name=approval_fields,json=approvalFields,proto3" json:"approval_fields,omitempty"`
+	// Saved search queries that admins defined for this project.
+	SavedQueries []*ProjectSavedQuery `protobuf:"bytes,8,rep,name=saved_queries,json=savedQueries,proto3" json:"saved_queries,omitempty"`
+}
+
+func (x *GatherProjectEnvironmentResponse) Reset() {
+	*x = GatherProjectEnvironmentResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GatherProjectEnvironmentResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatherProjectEnvironmentResponse) ProtoMessage() {}
+
+func (x *GatherProjectEnvironmentResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatherProjectEnvironmentResponse.ProtoReflect.Descriptor instead.
+func (*GatherProjectEnvironmentResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GatherProjectEnvironmentResponse) GetProject() *Project {
+	if x != nil {
+		return x.Project
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetProjectConfig() *ProjectConfig {
+	if x != nil {
+		return x.ProjectConfig
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetStatuses() []*StatusDef {
+	if x != nil {
+		return x.Statuses
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetWellKnownLabels() []*LabelDef {
+	if x != nil {
+		return x.WellKnownLabels
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetComponents() []*ComponentDef {
+	if x != nil {
+		return x.Components
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetFields() []*FieldDef {
+	if x != nil {
+		return x.Fields
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetApprovalFields() []*ApprovalDef {
+	if x != nil {
+		return x.ApprovalFields
+	}
+	return nil
+}
+
+func (x *GatherProjectEnvironmentResponse) GetSavedQueries() []*ProjectSavedQuery {
+	if x != nil {
+		return x.SavedQueries
+	}
+	return nil
+}
+
+// The request message for Frontend.GatherProjectMembershipsForUser.
+// Next available tag: 2
+type GatherProjectMembershipsForUserRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the user to request.
+	User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
+}
+
+func (x *GatherProjectMembershipsForUserRequest) Reset() {
+	*x = GatherProjectMembershipsForUserRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GatherProjectMembershipsForUserRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatherProjectMembershipsForUserRequest) ProtoMessage() {}
+
+func (x *GatherProjectMembershipsForUserRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatherProjectMembershipsForUserRequest.ProtoReflect.Descriptor instead.
+func (*GatherProjectMembershipsForUserRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *GatherProjectMembershipsForUserRequest) GetUser() string {
+	if x != nil {
+		return x.User
+	}
+	return ""
+}
+
+// The response message for Frontend.GatherProjectMembershipsForUser.
+// Next available tag: 2
+type GatherProjectMembershipsForUserResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The projects that the user is a member of.
+	ProjectMemberships []*ProjectMember `protobuf:"bytes,1,rep,name=project_memberships,json=projectMemberships,proto3" json:"project_memberships,omitempty"`
+}
+
+func (x *GatherProjectMembershipsForUserResponse) Reset() {
+	*x = GatherProjectMembershipsForUserResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GatherProjectMembershipsForUserResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatherProjectMembershipsForUserResponse) ProtoMessage() {}
+
+func (x *GatherProjectMembershipsForUserResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatherProjectMembershipsForUserResponse.ProtoReflect.Descriptor instead.
+func (*GatherProjectMembershipsForUserResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *GatherProjectMembershipsForUserResponse) GetProjectMemberships() []*ProjectMember {
+	if x != nil {
+		return x.ProjectMemberships
+	}
+	return nil
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDesc = []byte{
+	0x0a, 0x4d, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x66, 0x72, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x64, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
+	0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x1a, 0x1f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62,
+	0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x54, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72,
+	0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61,
+	0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
+	0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61,
+	0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x58,
+	0x0a, 0x1f, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x45,
+	0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x12, 0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0xe0, 0x41, 0x02,
+	0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x22, 0xfe, 0x03, 0x0a, 0x20, 0x47, 0x61, 0x74,
+	0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f,
+	0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a,
+	0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x41, 0x0a,
+	0x0e, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x52, 0x0d, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+	0x12, 0x32, 0x0a, 0x08, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44, 0x65, 0x66, 0x52, 0x08, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x65, 0x73, 0x12, 0x41, 0x0a, 0x11, 0x77, 0x65, 0x6c, 0x6c, 0x5f, 0x6b, 0x6e, 0x6f,
+	0x77, 0x6e, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x15, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x61,
+	0x62, 0x65, 0x6c, 0x44, 0x65, 0x66, 0x52, 0x0f, 0x77, 0x65, 0x6c, 0x6c, 0x4b, 0x6e, 0x6f, 0x77,
+	0x6e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e,
+	0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x0a, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+	0x74, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64,
+	0x73, 0x12, 0x41, 0x0a, 0x0f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x5f, 0x66, 0x69,
+	0x65, 0x6c, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61,
+	0x6c, 0x44, 0x65, 0x66, 0x52, 0x0e, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x46, 0x69,
+	0x65, 0x6c, 0x64, 0x73, 0x12, 0x43, 0x0a, 0x0d, 0x73, 0x61, 0x76, 0x65, 0x64, 0x5f, 0x71, 0x75,
+	0x65, 0x72, 0x69, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x53, 0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x0c, 0x73, 0x61, 0x76,
+	0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x22, 0x55, 0x0a, 0x26, 0x47, 0x61, 0x74,
+	0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72,
+	0x73, 0x68, 0x69, 0x70, 0x73, 0x46, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x2b, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72,
+	0x22, 0x76, 0x0a, 0x27, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x46, 0x6f, 0x72, 0x55,
+	0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, 0x0a, 0x13, 0x70,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69,
+	0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65,
+	0x6d, 0x62, 0x65, 0x72, 0x52, 0x12, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x6d,
+	0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x32, 0x96, 0x02, 0x0a, 0x08, 0x46, 0x72, 0x6f,
+	0x6e, 0x74, 0x65, 0x6e, 0x64, 0x12, 0x79, 0x0a, 0x18, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x76, 0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e,
+	0x74, 0x12, 0x2c, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x76,
+	0x69, 0x72, 0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x2d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x47, 0x61,
+	0x74, 0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x45, 0x6e, 0x76, 0x69, 0x72,
+	0x6f, 0x6e, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x8e, 0x01, 0x0a, 0x1f, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x46, 0x6f, 0x72,
+	0x55, 0x73, 0x65, 0x72, 0x12, 0x33, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73, 0x46, 0x6f, 0x72, 0x55, 0x73,
+	0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x50, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x68, 0x69, 0x70, 0x73,
+	0x46, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d,
+	0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73,
+	0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73,
+	0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_goTypes = []interface{}{
+	(*GatherProjectEnvironmentRequest)(nil),         // 0: monorail.v3.GatherProjectEnvironmentRequest
+	(*GatherProjectEnvironmentResponse)(nil),        // 1: monorail.v3.GatherProjectEnvironmentResponse
+	(*GatherProjectMembershipsForUserRequest)(nil),  // 2: monorail.v3.GatherProjectMembershipsForUserRequest
+	(*GatherProjectMembershipsForUserResponse)(nil), // 3: monorail.v3.GatherProjectMembershipsForUserResponse
+	(*Project)(nil),           // 4: monorail.v3.Project
+	(*ProjectConfig)(nil),     // 5: monorail.v3.ProjectConfig
+	(*StatusDef)(nil),         // 6: monorail.v3.StatusDef
+	(*LabelDef)(nil),          // 7: monorail.v3.LabelDef
+	(*ComponentDef)(nil),      // 8: monorail.v3.ComponentDef
+	(*FieldDef)(nil),          // 9: monorail.v3.FieldDef
+	(*ApprovalDef)(nil),       // 10: monorail.v3.ApprovalDef
+	(*ProjectSavedQuery)(nil), // 11: monorail.v3.ProjectSavedQuery
+	(*ProjectMember)(nil),     // 12: monorail.v3.ProjectMember
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_depIdxs = []int32{
+	4,  // 0: monorail.v3.GatherProjectEnvironmentResponse.project:type_name -> monorail.v3.Project
+	5,  // 1: monorail.v3.GatherProjectEnvironmentResponse.project_config:type_name -> monorail.v3.ProjectConfig
+	6,  // 2: monorail.v3.GatherProjectEnvironmentResponse.statuses:type_name -> monorail.v3.StatusDef
+	7,  // 3: monorail.v3.GatherProjectEnvironmentResponse.well_known_labels:type_name -> monorail.v3.LabelDef
+	8,  // 4: monorail.v3.GatherProjectEnvironmentResponse.components:type_name -> monorail.v3.ComponentDef
+	9,  // 5: monorail.v3.GatherProjectEnvironmentResponse.fields:type_name -> monorail.v3.FieldDef
+	10, // 6: monorail.v3.GatherProjectEnvironmentResponse.approval_fields:type_name -> monorail.v3.ApprovalDef
+	11, // 7: monorail.v3.GatherProjectEnvironmentResponse.saved_queries:type_name -> monorail.v3.ProjectSavedQuery
+	12, // 8: monorail.v3.GatherProjectMembershipsForUserResponse.project_memberships:type_name -> monorail.v3.ProjectMember
+	0,  // 9: monorail.v3.Frontend.GatherProjectEnvironment:input_type -> monorail.v3.GatherProjectEnvironmentRequest
+	2,  // 10: monorail.v3.Frontend.GatherProjectMembershipsForUser:input_type -> monorail.v3.GatherProjectMembershipsForUserRequest
+	1,  // 11: monorail.v3.Frontend.GatherProjectEnvironment:output_type -> monorail.v3.GatherProjectEnvironmentResponse
+	3,  // 12: monorail.v3.Frontend.GatherProjectMembershipsForUser:output_type -> monorail.v3.GatherProjectMembershipsForUserResponse
+	11, // [11:13] is the sub-list for method output_type
+	9,  // [9:11] is the sub-list for method input_type
+	9,  // [9:9] is the sub-list for extension type_name
+	9,  // [9:9] is the sub-list for extension extendee
+	0,  // [0:9] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GatherProjectEnvironmentRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GatherProjectEnvironmentResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GatherProjectMembershipsForUserRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GatherProjectMembershipsForUserResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_depIdxs,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_frontend_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// FrontendClient is the client API for Frontend service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type FrontendClient interface {
+	// status: DO NOT USE
+	// Returns all project specific configurations needed for the SPA client.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if the project resource name provided is invalid.
+	//   NOT_FOUND if the parent project is not found.
+	//   PERMISSION_DENIED if user is not allowed to view this project.
+	GatherProjectEnvironment(ctx context.Context, in *GatherProjectEnvironmentRequest, opts ...grpc.CallOption) (*GatherProjectEnvironmentResponse, error)
+	// status: DO NOT USE
+	// Returns all of a given user's project memberships.
+	//
+	// Raises:
+	//   NOT_FOUND if the user is not found.
+	//   INVALID_ARGUMENT if the user resource name provided is invalid.
+	GatherProjectMembershipsForUser(ctx context.Context, in *GatherProjectMembershipsForUserRequest, opts ...grpc.CallOption) (*GatherProjectMembershipsForUserResponse, error)
+}
+type frontendPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewFrontendPRPCClient(client *prpc.Client) FrontendClient {
+	return &frontendPRPCClient{client}
+}
+
+func (c *frontendPRPCClient) GatherProjectEnvironment(ctx context.Context, in *GatherProjectEnvironmentRequest, opts ...grpc.CallOption) (*GatherProjectEnvironmentResponse, error) {
+	out := new(GatherProjectEnvironmentResponse)
+	err := c.client.Call(ctx, "monorail.v3.Frontend", "GatherProjectEnvironment", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *frontendPRPCClient) GatherProjectMembershipsForUser(ctx context.Context, in *GatherProjectMembershipsForUserRequest, opts ...grpc.CallOption) (*GatherProjectMembershipsForUserResponse, error) {
+	out := new(GatherProjectMembershipsForUserResponse)
+	err := c.client.Call(ctx, "monorail.v3.Frontend", "GatherProjectMembershipsForUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type frontendClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewFrontendClient(cc grpc.ClientConnInterface) FrontendClient {
+	return &frontendClient{cc}
+}
+
+func (c *frontendClient) GatherProjectEnvironment(ctx context.Context, in *GatherProjectEnvironmentRequest, opts ...grpc.CallOption) (*GatherProjectEnvironmentResponse, error) {
+	out := new(GatherProjectEnvironmentResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Frontend/GatherProjectEnvironment", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *frontendClient) GatherProjectMembershipsForUser(ctx context.Context, in *GatherProjectMembershipsForUserRequest, opts ...grpc.CallOption) (*GatherProjectMembershipsForUserResponse, error) {
+	out := new(GatherProjectMembershipsForUserResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Frontend/GatherProjectMembershipsForUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// FrontendServer is the server API for Frontend service.
+type FrontendServer interface {
+	// status: DO NOT USE
+	// Returns all project specific configurations needed for the SPA client.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if the project resource name provided is invalid.
+	//   NOT_FOUND if the parent project is not found.
+	//   PERMISSION_DENIED if user is not allowed to view this project.
+	GatherProjectEnvironment(context.Context, *GatherProjectEnvironmentRequest) (*GatherProjectEnvironmentResponse, error)
+	// status: DO NOT USE
+	// Returns all of a given user's project memberships.
+	//
+	// Raises:
+	//   NOT_FOUND if the user is not found.
+	//   INVALID_ARGUMENT if the user resource name provided is invalid.
+	GatherProjectMembershipsForUser(context.Context, *GatherProjectMembershipsForUserRequest) (*GatherProjectMembershipsForUserResponse, error)
+}
+
+// UnimplementedFrontendServer can be embedded to have forward compatible implementations.
+type UnimplementedFrontendServer struct {
+}
+
+func (*UnimplementedFrontendServer) GatherProjectEnvironment(context.Context, *GatherProjectEnvironmentRequest) (*GatherProjectEnvironmentResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GatherProjectEnvironment not implemented")
+}
+func (*UnimplementedFrontendServer) GatherProjectMembershipsForUser(context.Context, *GatherProjectMembershipsForUserRequest) (*GatherProjectMembershipsForUserResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GatherProjectMembershipsForUser not implemented")
+}
+
+func RegisterFrontendServer(s prpc.Registrar, srv FrontendServer) {
+	s.RegisterService(&_Frontend_serviceDesc, srv)
+}
+
+func _Frontend_GatherProjectEnvironment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GatherProjectEnvironmentRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(FrontendServer).GatherProjectEnvironment(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Frontend/GatherProjectEnvironment",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(FrontendServer).GatherProjectEnvironment(ctx, req.(*GatherProjectEnvironmentRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Frontend_GatherProjectMembershipsForUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GatherProjectMembershipsForUserRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(FrontendServer).GatherProjectMembershipsForUser(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Frontend/GatherProjectMembershipsForUser",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(FrontendServer).GatherProjectMembershipsForUser(ctx, req.(*GatherProjectMembershipsForUserRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Frontend_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "monorail.v3.Frontend",
+	HandlerType: (*FrontendServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GatherProjectEnvironment",
+			Handler:    _Frontend_GatherProjectEnvironment_Handler,
+		},
+		{
+			MethodName: "GatherProjectMembershipsForUser",
+			Handler:    _Frontend_GatherProjectMembershipsForUser_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/frontend.proto",
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/frontend.proto b/analysis/internal/bugs/monorail/api_proto/frontend.proto
new file mode 100644
index 0000000..d6dcc10
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/frontend.proto
@@ -0,0 +1,85 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/project_objects.proto";
+
+// ***DO NOT CALL rpcs IN THIS SERVICE.***
+// This service is for Monorail's frontend only.
+
+service Frontend {
+  // status: DO NOT USE
+  // Returns all project specific configurations needed for the SPA client.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if the project resource name provided is invalid.
+  //   NOT_FOUND if the parent project is not found.
+  //   PERMISSION_DENIED if user is not allowed to view this project.
+  rpc GatherProjectEnvironment (GatherProjectEnvironmentRequest) returns (GatherProjectEnvironmentResponse) {};
+
+  // status: DO NOT USE
+  // Returns all of a given user's project memberships.
+  //
+  // Raises:
+  //   NOT_FOUND if the user is not found.
+  //   INVALID_ARGUMENT if the user resource name provided is invalid.
+  rpc GatherProjectMembershipsForUser (GatherProjectMembershipsForUserRequest)
+    returns (GatherProjectMembershipsForUserResponse) {}
+}
+
+
+// Request message for GatherProjectEnvironment
+// Next available tag: 2
+message GatherProjectEnvironmentRequest {
+  // The name of the project these config environments belong to.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+    (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// Response message for GatherProjectEnvironment
+// Next available tag: 9
+message GatherProjectEnvironmentResponse {
+  // Project definitions such as display_name and summary.
+  Project project = 1;
+  // Configurations of this project such as default search term,
+  // default templates for members and non members.
+  ProjectConfig project_config = 2;
+  // List of statuses that belong to this project.
+  repeated StatusDef statuses = 3;
+  // List of well known labels that belong to this project.
+  repeated LabelDef well_known_labels = 4;
+  // List of components that belong to this project.
+  repeated ComponentDef components = 5;
+  // List of custom fields that belong to this project.
+  repeated FieldDef fields = 6;
+  // List of approval fields that belong to this project.
+  repeated ApprovalDef approval_fields = 7;
+  // Saved search queries that admins defined for this project.
+  repeated ProjectSavedQuery saved_queries = 8;
+}
+
+// The request message for Frontend.GatherProjectMembershipsForUser.
+// Next available tag: 2
+message GatherProjectMembershipsForUserRequest {
+  // The name of the user to request.
+  string user = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"}];
+}
+
+// The response message for Frontend.GatherProjectMembershipsForUser.
+// Next available tag: 2
+message GatherProjectMembershipsForUserResponse {
+  // The projects that the user is a member of.
+  repeated ProjectMember project_memberships = 1;
+}
\ No newline at end of file
diff --git a/analysis/internal/bugs/monorail/api_proto/gen.go b/analysis/internal/bugs/monorail/api_proto/gen.go
new file mode 100644
index 0000000..37ccbcf
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/gen.go
@@ -0,0 +1,18 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package api_proto
+
+//go:generate ./copy.sh
+//go:generate cproto
diff --git a/analysis/internal/bugs/monorail/api_proto/hotlists.pb.go b/analysis/internal/bugs/monorail/api_proto/hotlists.pb.go
new file mode 100644
index 0000000..0715f0a
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/hotlists.pb.go
@@ -0,0 +1,1760 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/hotlists.proto
+
+package api_proto
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+	fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Request message for CreateHotlist method.
+// Next available tag: 2
+type CreateHotlistRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The hotlist to create.
+	// `hotlist.owner` must be empty. The owner of the new hotlist will be
+	// set to the requester.
+	Hotlist *Hotlist `protobuf:"bytes,1,opt,name=hotlist,proto3" json:"hotlist,omitempty"`
+}
+
+func (x *CreateHotlistRequest) Reset() {
+	*x = CreateHotlistRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CreateHotlistRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateHotlistRequest) ProtoMessage() {}
+
+func (x *CreateHotlistRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateHotlistRequest.ProtoReflect.Descriptor instead.
+func (*CreateHotlistRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *CreateHotlistRequest) GetHotlist() *Hotlist {
+	if x != nil {
+		return x.Hotlist
+	}
+	return nil
+}
+
+// Request message for GetHotlist method.
+// Next available tag: 2
+type GetHotlistRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the hotlist to retrieve.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetHotlistRequest) Reset() {
+	*x = GetHotlistRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetHotlistRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetHotlistRequest) ProtoMessage() {}
+
+func (x *GetHotlistRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetHotlistRequest.ProtoReflect.Descriptor instead.
+func (*GetHotlistRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GetHotlistRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// Request message for UpdateHotlist method.
+// Next available tag: 2
+type UpdateHotlistRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The hotlist's `name` field is used to identify the hotlist to be updated.
+	Hotlist *Hotlist `protobuf:"bytes,1,opt,name=hotlist,proto3" json:"hotlist,omitempty"`
+	// The list of fields to be updated.
+	UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"`
+}
+
+func (x *UpdateHotlistRequest) Reset() {
+	*x = UpdateHotlistRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UpdateHotlistRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateHotlistRequest) ProtoMessage() {}
+
+func (x *UpdateHotlistRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateHotlistRequest.ProtoReflect.Descriptor instead.
+func (*UpdateHotlistRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *UpdateHotlistRequest) GetHotlist() *Hotlist {
+	if x != nil {
+		return x.Hotlist
+	}
+	return nil
+}
+
+func (x *UpdateHotlistRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
+	if x != nil {
+		return x.UpdateMask
+	}
+	return nil
+}
+
+// Request message for ListHotlistItems method.
+// Next available tag: 5
+type ListHotlistItemsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The parent hotlist, which owns this collection of items.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	// If unspecified, at most 1000 items will be returned.
+	// The maximum value is 1000; values above 1000 will be coerced to 1000.
+	PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// The string of comma separated field names used to order the items.
+	// Adding '-' before a field, reverses the sort order.
+	// E.g. 'stars,-status' sorts the items by number of stars low to high, then
+	// status high to low.
+	// If unspecified, items will be ordered by their rank in the parent.
+	OrderBy string `protobuf:"bytes,3,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"`
+	// A page token, received from a previous `ListHotlistItems` call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to `ListHotlistItems` must
+	// match the call that provided the page token.
+	PageToken string `protobuf:"bytes,4,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *ListHotlistItemsRequest) Reset() {
+	*x = ListHotlistItemsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListHotlistItemsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListHotlistItemsRequest) ProtoMessage() {}
+
+func (x *ListHotlistItemsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListHotlistItemsRequest.ProtoReflect.Descriptor instead.
+func (*ListHotlistItemsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ListHotlistItemsRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *ListHotlistItemsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *ListHotlistItemsRequest) GetOrderBy() string {
+	if x != nil {
+		return x.OrderBy
+	}
+	return ""
+}
+
+func (x *ListHotlistItemsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// Response to ListHotlistItems call.
+// Next available tag: 3
+type ListHotlistItemsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The items from the specified hotlist.
+	Items []*HotlistItem `protobuf:"bytes,1,rep,name=items,proto3" json:"items,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *ListHotlistItemsResponse) Reset() {
+	*x = ListHotlistItemsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListHotlistItemsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListHotlistItemsResponse) ProtoMessage() {}
+
+func (x *ListHotlistItemsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListHotlistItemsResponse.ProtoReflect.Descriptor instead.
+func (*ListHotlistItemsResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ListHotlistItemsResponse) GetItems() []*HotlistItem {
+	if x != nil {
+		return x.Items
+	}
+	return nil
+}
+
+func (x *ListHotlistItemsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// The request used to rerank a Hotlist.
+// Next available tag: 4
+type RerankHotlistItemsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the Hotlist to rerank.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// HotlistItems to be moved. The order of `hotlist_items` will
+	// determine the order of these items after they have been moved.
+	// E.g. With items [a, b, c, d, e], moving [d, c] to `target_position` 3, will
+	// result in items [a, b, e, d, c].
+	HotlistItems []string `protobuf:"bytes,2,rep,name=hotlist_items,json=hotlistItems,proto3" json:"hotlist_items,omitempty"`
+	// Target starting position of the moved items.
+	// `target_position` must be between 0 and (# hotlist items - # items being moved).
+	TargetPosition uint32 `protobuf:"varint,3,opt,name=target_position,json=targetPosition,proto3" json:"target_position,omitempty"`
+}
+
+func (x *RerankHotlistItemsRequest) Reset() {
+	*x = RerankHotlistItemsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *RerankHotlistItemsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RerankHotlistItemsRequest) ProtoMessage() {}
+
+func (x *RerankHotlistItemsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RerankHotlistItemsRequest.ProtoReflect.Descriptor instead.
+func (*RerankHotlistItemsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *RerankHotlistItemsRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *RerankHotlistItemsRequest) GetHotlistItems() []string {
+	if x != nil {
+		return x.HotlistItems
+	}
+	return nil
+}
+
+func (x *RerankHotlistItemsRequest) GetTargetPosition() uint32 {
+	if x != nil {
+		return x.TargetPosition
+	}
+	return 0
+}
+
+// Request message for an AddHotlistItems call.
+// Next available tag: 4
+type AddHotlistItemsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the Hotlist to add new items to.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// Resource names of Issues to associate with new HotlistItems added to `parent`.
+	Issues []string `protobuf:"bytes,2,rep,name=issues,proto3" json:"issues,omitempty"`
+	// Target starting position of the new items.
+	// `target_position` must be between [0 and # of items that currently exist in
+	// `parent`]. The request will fail if a specified `target_position` is outside
+	// of this range.
+	// New HotlistItems added to a non-last position of the hotlist will
+	// cause ranks of existing HotlistItems below `target_position` to be adjusted.
+	// If no `target_position` is given, new items will be added to the end of
+	// `parent`.
+	TargetPosition uint32 `protobuf:"varint,3,opt,name=target_position,json=targetPosition,proto3" json:"target_position,omitempty"`
+}
+
+func (x *AddHotlistItemsRequest) Reset() {
+	*x = AddHotlistItemsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *AddHotlistItemsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AddHotlistItemsRequest) ProtoMessage() {}
+
+func (x *AddHotlistItemsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use AddHotlistItemsRequest.ProtoReflect.Descriptor instead.
+func (*AddHotlistItemsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *AddHotlistItemsRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *AddHotlistItemsRequest) GetIssues() []string {
+	if x != nil {
+		return x.Issues
+	}
+	return nil
+}
+
+func (x *AddHotlistItemsRequest) GetTargetPosition() uint32 {
+	if x != nil {
+		return x.TargetPosition
+	}
+	return 0
+}
+
+// Request message for a RemoveHotlistItems call.
+// Next available tag: 3
+type RemoveHotlistItemsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the Hotlist to remove items from.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// Resource names of Issues associated with HotlistItems that should be removed.
+	Issues []string `protobuf:"bytes,2,rep,name=issues,proto3" json:"issues,omitempty"`
+}
+
+func (x *RemoveHotlistItemsRequest) Reset() {
+	*x = RemoveHotlistItemsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *RemoveHotlistItemsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RemoveHotlistItemsRequest) ProtoMessage() {}
+
+func (x *RemoveHotlistItemsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RemoveHotlistItemsRequest.ProtoReflect.Descriptor instead.
+func (*RemoveHotlistItemsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *RemoveHotlistItemsRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *RemoveHotlistItemsRequest) GetIssues() []string {
+	if x != nil {
+		return x.Issues
+	}
+	return nil
+}
+
+// Request message for a RemoveHotlistEditors call.
+// Next available tag: 3
+type RemoveHotlistEditorsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the Hotlist to remove editors from.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Resource names of Users associated with the hotlist that should be removed.
+	Editors []string `protobuf:"bytes,2,rep,name=editors,proto3" json:"editors,omitempty"`
+}
+
+func (x *RemoveHotlistEditorsRequest) Reset() {
+	*x = RemoveHotlistEditorsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *RemoveHotlistEditorsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RemoveHotlistEditorsRequest) ProtoMessage() {}
+
+func (x *RemoveHotlistEditorsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RemoveHotlistEditorsRequest.ProtoReflect.Descriptor instead.
+func (*RemoveHotlistEditorsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *RemoveHotlistEditorsRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *RemoveHotlistEditorsRequest) GetEditors() []string {
+	if x != nil {
+		return x.Editors
+	}
+	return nil
+}
+
+// Request message for a GatherHotlistsForUser call.
+// Next available tag: 2
+type GatherHotlistsForUserRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the user whose hotlists we want to fetch.
+	User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
+}
+
+func (x *GatherHotlistsForUserRequest) Reset() {
+	*x = GatherHotlistsForUserRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GatherHotlistsForUserRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatherHotlistsForUserRequest) ProtoMessage() {}
+
+func (x *GatherHotlistsForUserRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatherHotlistsForUserRequest.ProtoReflect.Descriptor instead.
+func (*GatherHotlistsForUserRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *GatherHotlistsForUserRequest) GetUser() string {
+	if x != nil {
+		return x.User
+	}
+	return ""
+}
+
+// Response message for a GatherHotlistsForUser call.
+// Next available tag: 2
+type GatherHotlistsForUserResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Hotlists []*Hotlist `protobuf:"bytes,1,rep,name=hotlists,proto3" json:"hotlists,omitempty"`
+}
+
+func (x *GatherHotlistsForUserResponse) Reset() {
+	*x = GatherHotlistsForUserResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[10]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GatherHotlistsForUserResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GatherHotlistsForUserResponse) ProtoMessage() {}
+
+func (x *GatherHotlistsForUserResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[10]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GatherHotlistsForUserResponse.ProtoReflect.Descriptor instead.
+func (*GatherHotlistsForUserResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *GatherHotlistsForUserResponse) GetHotlists() []*Hotlist {
+	if x != nil {
+		return x.Hotlists
+	}
+	return nil
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDesc = []byte{
+	0x0a, 0x4d, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
+	0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x1a, 0x54, 0x67, 0x6f,
+	0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75,
+	0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65,
+	0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x66, 0x65, 0x61,
+	0x74, 0x75, 0x72, 0x65, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x20, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69,
+	0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72,
+	0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x4b, 0x0a,
+	0x14, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x07, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x42, 0x03, 0xe0, 0x41,
+	0x02, 0x52, 0x07, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x22, 0x46, 0x0a, 0x11, 0x47, 0x65,
+	0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+	0x31, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xe0,
+	0x41, 0x02, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67,
+	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x04, 0x6e, 0x61,
+	0x6d, 0x65, 0x22, 0xa7, 0x01, 0x0a, 0x14, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x48, 0x6f, 0x74,
+	0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x4d, 0x0a, 0x07, 0x68,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74, 0x6c, 0x69,
+	0x73, 0x74, 0x42, 0x1d, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e,
+	0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73,
+	0x74, 0x52, 0x07, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x40, 0x0a, 0x0b, 0x75, 0x70,
+	0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+	0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x42, 0x03, 0xe0, 0x41, 0x02,
+	0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b, 0x22, 0xa7, 0x01, 0x0a,
+	0x17, 0x4c, 0x69, 0x73, 0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65,
+	0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x17,
+	0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12,
+	0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x19, 0x0a, 0x08,
+	0x6f, 0x72, 0x64, 0x65, 0x72, 0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
+	0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x79, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f,
+	0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67,
+	0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x72, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x48, 0x6f,
+	0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65,
+	0x6d, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f,
+	0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78,
+	0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xc4, 0x01, 0x0a, 0x19, 0x52,
+	0x65, 0x72, 0x61, 0x6e, 0x6b, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69,
+	0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69,
+	0x73, 0x74, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, 0x0a, 0x0d, 0x68,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x5f, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18, 0x02, 0x20, 0x03,
+	0x28, 0x09, 0x42, 0x21, 0xfa, 0x41, 0x1b, 0x0a, 0x19, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62,
+	0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74,
+	0x65, 0x6d, 0xe0, 0x41, 0x02, 0x52, 0x0c, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74,
+	0x65, 0x6d, 0x73, 0x12, 0x2c, 0x0a, 0x0f, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x5f, 0x70, 0x6f,
+	0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x42, 0x03, 0xe0, 0x41,
+	0x02, 0x52, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f,
+	0x6e, 0x22, 0xad, 0x01, 0x0a, 0x16, 0x41, 0x64, 0x64, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74,
+	0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x06,
+	0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xe0, 0x41,
+	0x02, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72,
+	0x65, 0x6e, 0x74, 0x12, 0x33, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x18, 0x02, 0x20,
+	0x03, 0x28, 0x09, 0x42, 0x1b, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x15, 0x0a, 0x13, 0x61, 0x70, 0x69,
+	0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75, 0x65,
+	0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x12, 0x27, 0x0a, 0x0f, 0x74, 0x61, 0x72, 0x67,
+	0x65, 0x74, 0x5f, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x0d, 0x52, 0x0e, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x50, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f,
+	0x6e, 0x22, 0x87, 0x01, 0x0a, 0x19, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x48, 0x6f, 0x74, 0x6c,
+	0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+	0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x1d, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62,
+	0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x06,
+	0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x33, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73,
+	0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x42, 0x1b, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x15, 0x0a, 0x13,
+	0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x22, 0x86, 0x01, 0x0a, 0x1b,
+	0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x45, 0x64, 0x69,
+	0x74, 0x6f, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xe0, 0x41, 0x02, 0xfa, 0x41,
+	0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x34,
+	0x0a, 0x07, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x42,
+	0x1a, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62,
+	0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x07, 0x65, 0x64, 0x69,
+	0x74, 0x6f, 0x72, 0x73, 0x22, 0x4e, 0x0a, 0x1c, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x48, 0x6f,
+	0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x46, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x42, 0x1a, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e,
+	0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04,
+	0x75, 0x73, 0x65, 0x72, 0x22, 0x51, 0x0a, 0x1d, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x48, 0x6f,
+	0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x46, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x08, 0x68, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74,
+	0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x08, 0x68,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x32, 0xe6, 0x06, 0x0a, 0x08, 0x48, 0x6f, 0x74, 0x6c,
+	0x69, 0x73, 0x74, 0x73, 0x12, 0x4a, 0x0a, 0x0d, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x48, 0x6f,
+	0x74, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73,
+	0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x22, 0x00,
+	0x12, 0x44, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x1e,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x47, 0x65, 0x74,
+	0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74,
+	0x6c, 0x69, 0x73, 0x74, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+	0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x48, 0x6f, 0x74, 0x6c,
+	0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74,
+	0x22, 0x00, 0x12, 0x49, 0x0a, 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x48, 0x6f, 0x74, 0x6c,
+	0x69, 0x73, 0x74, 0x12, 0x1e, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x47, 0x65, 0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x61, 0x0a,
+	0x10, 0x4c, 0x69, 0x73, 0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d,
+	0x73, 0x12, 0x24, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x4c, 0x69, 0x73, 0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73,
+	0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x56, 0x0a, 0x12, 0x52, 0x65, 0x72, 0x61, 0x6e, 0x6b, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73,
+	0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x52, 0x65, 0x72, 0x61, 0x6e, 0x6b, 0x48, 0x6f, 0x74, 0x6c, 0x69,
+	0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16,
+	0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+	0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x50, 0x0a, 0x0f, 0x41, 0x64, 0x64, 0x48,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x12, 0x23, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x64, 0x64, 0x48, 0x6f, 0x74,
+	0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x56, 0x0a, 0x12, 0x52, 0x65,
+	0x6d, 0x6f, 0x76, 0x65, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d, 0x73,
+	0x12, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x52,
+	0x65, 0x6d, 0x6f, 0x76, 0x65, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x49, 0x74, 0x65, 0x6d,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+	0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79,
+	0x22, 0x00, 0x12, 0x5a, 0x0a, 0x14, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x48, 0x6f, 0x74, 0x6c,
+	0x69, 0x73, 0x74, 0x45, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x73, 0x12, 0x28, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x48,
+	0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x45, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x73, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x70,
+	0x0a, 0x15, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x73,
+	0x46, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x12, 0x29, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x48, 0x6f, 0x74, 0x6c,
+	0x69, 0x73, 0x74, 0x73, 0x46, 0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x47, 0x61, 0x74, 0x68, 0x65, 0x72, 0x48, 0x6f, 0x74, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x46,
+	0x6f, 0x72, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e,
+	0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69,
+	0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_goTypes = []interface{}{
+	(*CreateHotlistRequest)(nil),          // 0: monorail.v3.CreateHotlistRequest
+	(*GetHotlistRequest)(nil),             // 1: monorail.v3.GetHotlistRequest
+	(*UpdateHotlistRequest)(nil),          // 2: monorail.v3.UpdateHotlistRequest
+	(*ListHotlistItemsRequest)(nil),       // 3: monorail.v3.ListHotlistItemsRequest
+	(*ListHotlistItemsResponse)(nil),      // 4: monorail.v3.ListHotlistItemsResponse
+	(*RerankHotlistItemsRequest)(nil),     // 5: monorail.v3.RerankHotlistItemsRequest
+	(*AddHotlistItemsRequest)(nil),        // 6: monorail.v3.AddHotlistItemsRequest
+	(*RemoveHotlistItemsRequest)(nil),     // 7: monorail.v3.RemoveHotlistItemsRequest
+	(*RemoveHotlistEditorsRequest)(nil),   // 8: monorail.v3.RemoveHotlistEditorsRequest
+	(*GatherHotlistsForUserRequest)(nil),  // 9: monorail.v3.GatherHotlistsForUserRequest
+	(*GatherHotlistsForUserResponse)(nil), // 10: monorail.v3.GatherHotlistsForUserResponse
+	(*Hotlist)(nil),                       // 11: monorail.v3.Hotlist
+	(*fieldmaskpb.FieldMask)(nil),         // 12: google.protobuf.FieldMask
+	(*HotlistItem)(nil),                   // 13: monorail.v3.HotlistItem
+	(*emptypb.Empty)(nil),                 // 14: google.protobuf.Empty
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_depIdxs = []int32{
+	11, // 0: monorail.v3.CreateHotlistRequest.hotlist:type_name -> monorail.v3.Hotlist
+	11, // 1: monorail.v3.UpdateHotlistRequest.hotlist:type_name -> monorail.v3.Hotlist
+	12, // 2: monorail.v3.UpdateHotlistRequest.update_mask:type_name -> google.protobuf.FieldMask
+	13, // 3: monorail.v3.ListHotlistItemsResponse.items:type_name -> monorail.v3.HotlistItem
+	11, // 4: monorail.v3.GatherHotlistsForUserResponse.hotlists:type_name -> monorail.v3.Hotlist
+	0,  // 5: monorail.v3.Hotlists.CreateHotlist:input_type -> monorail.v3.CreateHotlistRequest
+	1,  // 6: monorail.v3.Hotlists.GetHotlist:input_type -> monorail.v3.GetHotlistRequest
+	2,  // 7: monorail.v3.Hotlists.UpdateHotlist:input_type -> monorail.v3.UpdateHotlistRequest
+	1,  // 8: monorail.v3.Hotlists.DeleteHotlist:input_type -> monorail.v3.GetHotlistRequest
+	3,  // 9: monorail.v3.Hotlists.ListHotlistItems:input_type -> monorail.v3.ListHotlistItemsRequest
+	5,  // 10: monorail.v3.Hotlists.RerankHotlistItems:input_type -> monorail.v3.RerankHotlistItemsRequest
+	6,  // 11: monorail.v3.Hotlists.AddHotlistItems:input_type -> monorail.v3.AddHotlistItemsRequest
+	7,  // 12: monorail.v3.Hotlists.RemoveHotlistItems:input_type -> monorail.v3.RemoveHotlistItemsRequest
+	8,  // 13: monorail.v3.Hotlists.RemoveHotlistEditors:input_type -> monorail.v3.RemoveHotlistEditorsRequest
+	9,  // 14: monorail.v3.Hotlists.GatherHotlistsForUser:input_type -> monorail.v3.GatherHotlistsForUserRequest
+	11, // 15: monorail.v3.Hotlists.CreateHotlist:output_type -> monorail.v3.Hotlist
+	11, // 16: monorail.v3.Hotlists.GetHotlist:output_type -> monorail.v3.Hotlist
+	11, // 17: monorail.v3.Hotlists.UpdateHotlist:output_type -> monorail.v3.Hotlist
+	14, // 18: monorail.v3.Hotlists.DeleteHotlist:output_type -> google.protobuf.Empty
+	4,  // 19: monorail.v3.Hotlists.ListHotlistItems:output_type -> monorail.v3.ListHotlistItemsResponse
+	14, // 20: monorail.v3.Hotlists.RerankHotlistItems:output_type -> google.protobuf.Empty
+	14, // 21: monorail.v3.Hotlists.AddHotlistItems:output_type -> google.protobuf.Empty
+	14, // 22: monorail.v3.Hotlists.RemoveHotlistItems:output_type -> google.protobuf.Empty
+	14, // 23: monorail.v3.Hotlists.RemoveHotlistEditors:output_type -> google.protobuf.Empty
+	10, // 24: monorail.v3.Hotlists.GatherHotlistsForUser:output_type -> monorail.v3.GatherHotlistsForUserResponse
+	15, // [15:25] is the sub-list for method output_type
+	5,  // [5:15] is the sub-list for method input_type
+	5,  // [5:5] is the sub-list for extension type_name
+	5,  // [5:5] is the sub-list for extension extendee
+	0,  // [0:5] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_feature_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CreateHotlistRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetHotlistRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UpdateHotlistRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListHotlistItemsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListHotlistItemsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*RerankHotlistItemsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*AddHotlistItemsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*RemoveHotlistItemsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*RemoveHotlistEditorsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GatherHotlistsForUserRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GatherHotlistsForUserResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   11,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_depIdxs,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_hotlists_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// HotlistsClient is the client API for Hotlists service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type HotlistsClient interface {
+	// status: NOT READY
+	// Creates a new hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if some given hotlist editors do not exist.
+	//   ALREADY_EXISTS if a hotlist with the same name owned by the user
+	//   already exists.
+	//   INVALID_ARGUMENT if a `hotlist.owner` is given.
+	CreateHotlist(ctx context.Context, in *CreateHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error)
+	// status: NOT READY
+	// Returns the requested Hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to view the hotlist.
+	//   INVALID_ARGUMENT if the given resource name is not valid.
+	GetHotlist(ctx context.Context, in *GetHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error)
+	// status: NOT READY
+	// Updates a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to update the hotlist.
+	//   INVALID_ARGUMENT if required fields are missing.
+	UpdateHotlist(ctx context.Context, in *UpdateHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error)
+	// status: NOT READY
+	// Deletes a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to delete the hotlist.
+	DeleteHotlist(ctx context.Context, in *GetHotlistRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Returns a list of all HotlistItems in the hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the parent hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to view the hotlist.
+	//   INVALID_ARGUMENT if the page_token or given hotlist resource name is not
+	//   valid.
+	ListHotlistItems(ctx context.Context, in *ListHotlistItemsRequest, opts ...grpc.CallOption) (*ListHotlistItemsResponse, error)
+	// status: NOT READY
+	// Reranks a hotlist's items.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist or issues to rerank are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to rerank the hotlist
+	//   or view issues they're trying to rerank.
+	//   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+	//   is empty or contains items not in the Hotlist.
+	RerankHotlistItems(ctx context.Context, in *RerankHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Adds new items associated with given issues to a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the parent hotlist or issues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to edit the hotlist or
+	//   view issues they are trying to add.
+	//   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+	//   is empty or contains items not in the Hotlist.
+	AddHotlistItems(ctx context.Context, in *AddHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Removes items associated with given issues from a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the parent hotlist or issues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to edit the hotlist or
+	//   view issues they are trying to remove.
+	//   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+	//   is empty or contains items not in the Hotlist.
+	RemoveHotlistItems(ctx context.Context, in *RemoveHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Removes editors assigned to a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to remove all specified
+	//   editors from the hotlist.
+	//   INVALID_ARGUMENT if any specified editors are not in the hotlist.
+	RemoveHotlistEditors(ctx context.Context, in *RemoveHotlistEditorsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Gathers all viewable hotlists that a user is a member of.
+	//
+	// Raises:
+	//   NOT_FOUND if the user is not found.
+	//   INVALID_ARGUMENT if the `user` is invalid.
+	GatherHotlistsForUser(ctx context.Context, in *GatherHotlistsForUserRequest, opts ...grpc.CallOption) (*GatherHotlistsForUserResponse, error)
+}
+type hotlistsPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewHotlistsPRPCClient(client *prpc.Client) HotlistsClient {
+	return &hotlistsPRPCClient{client}
+}
+
+func (c *hotlistsPRPCClient) CreateHotlist(ctx context.Context, in *CreateHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error) {
+	out := new(Hotlist)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "CreateHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) GetHotlist(ctx context.Context, in *GetHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error) {
+	out := new(Hotlist)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "GetHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) UpdateHotlist(ctx context.Context, in *UpdateHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error) {
+	out := new(Hotlist)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "UpdateHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) DeleteHotlist(ctx context.Context, in *GetHotlistRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "DeleteHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) ListHotlistItems(ctx context.Context, in *ListHotlistItemsRequest, opts ...grpc.CallOption) (*ListHotlistItemsResponse, error) {
+	out := new(ListHotlistItemsResponse)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "ListHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) RerankHotlistItems(ctx context.Context, in *RerankHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "RerankHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) AddHotlistItems(ctx context.Context, in *AddHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "AddHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) RemoveHotlistItems(ctx context.Context, in *RemoveHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "RemoveHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) RemoveHotlistEditors(ctx context.Context, in *RemoveHotlistEditorsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "RemoveHotlistEditors", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsPRPCClient) GatherHotlistsForUser(ctx context.Context, in *GatherHotlistsForUserRequest, opts ...grpc.CallOption) (*GatherHotlistsForUserResponse, error) {
+	out := new(GatherHotlistsForUserResponse)
+	err := c.client.Call(ctx, "monorail.v3.Hotlists", "GatherHotlistsForUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type hotlistsClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewHotlistsClient(cc grpc.ClientConnInterface) HotlistsClient {
+	return &hotlistsClient{cc}
+}
+
+func (c *hotlistsClient) CreateHotlist(ctx context.Context, in *CreateHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error) {
+	out := new(Hotlist)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/CreateHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) GetHotlist(ctx context.Context, in *GetHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error) {
+	out := new(Hotlist)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/GetHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) UpdateHotlist(ctx context.Context, in *UpdateHotlistRequest, opts ...grpc.CallOption) (*Hotlist, error) {
+	out := new(Hotlist)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/UpdateHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) DeleteHotlist(ctx context.Context, in *GetHotlistRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/DeleteHotlist", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) ListHotlistItems(ctx context.Context, in *ListHotlistItemsRequest, opts ...grpc.CallOption) (*ListHotlistItemsResponse, error) {
+	out := new(ListHotlistItemsResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/ListHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) RerankHotlistItems(ctx context.Context, in *RerankHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/RerankHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) AddHotlistItems(ctx context.Context, in *AddHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/AddHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) RemoveHotlistItems(ctx context.Context, in *RemoveHotlistItemsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/RemoveHotlistItems", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) RemoveHotlistEditors(ctx context.Context, in *RemoveHotlistEditorsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/RemoveHotlistEditors", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *hotlistsClient) GatherHotlistsForUser(ctx context.Context, in *GatherHotlistsForUserRequest, opts ...grpc.CallOption) (*GatherHotlistsForUserResponse, error) {
+	out := new(GatherHotlistsForUserResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Hotlists/GatherHotlistsForUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// HotlistsServer is the server API for Hotlists service.
+type HotlistsServer interface {
+	// status: NOT READY
+	// Creates a new hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if some given hotlist editors do not exist.
+	//   ALREADY_EXISTS if a hotlist with the same name owned by the user
+	//   already exists.
+	//   INVALID_ARGUMENT if a `hotlist.owner` is given.
+	CreateHotlist(context.Context, *CreateHotlistRequest) (*Hotlist, error)
+	// status: NOT READY
+	// Returns the requested Hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to view the hotlist.
+	//   INVALID_ARGUMENT if the given resource name is not valid.
+	GetHotlist(context.Context, *GetHotlistRequest) (*Hotlist, error)
+	// status: NOT READY
+	// Updates a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to update the hotlist.
+	//   INVALID_ARGUMENT if required fields are missing.
+	UpdateHotlist(context.Context, *UpdateHotlistRequest) (*Hotlist, error)
+	// status: NOT READY
+	// Deletes a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to delete the hotlist.
+	DeleteHotlist(context.Context, *GetHotlistRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Returns a list of all HotlistItems in the hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the parent hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to view the hotlist.
+	//   INVALID_ARGUMENT if the page_token or given hotlist resource name is not
+	//   valid.
+	ListHotlistItems(context.Context, *ListHotlistItemsRequest) (*ListHotlistItemsResponse, error)
+	// status: NOT READY
+	// Reranks a hotlist's items.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist or issues to rerank are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to rerank the hotlist
+	//   or view issues they're trying to rerank.
+	//   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+	//   is empty or contains items not in the Hotlist.
+	RerankHotlistItems(context.Context, *RerankHotlistItemsRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Adds new items associated with given issues to a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the parent hotlist or issues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to edit the hotlist or
+	//   view issues they are trying to add.
+	//   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+	//   is empty or contains items not in the Hotlist.
+	AddHotlistItems(context.Context, *AddHotlistItemsRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Removes items associated with given issues from a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the parent hotlist or issues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to edit the hotlist or
+	//   view issues they are trying to remove.
+	//   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+	//   is empty or contains items not in the Hotlist.
+	RemoveHotlistItems(context.Context, *RemoveHotlistItemsRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Removes editors assigned to a hotlist.
+	//
+	// Raises:
+	//   NOT_FOUND if the hotlist is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to remove all specified
+	//   editors from the hotlist.
+	//   INVALID_ARGUMENT if any specified editors are not in the hotlist.
+	RemoveHotlistEditors(context.Context, *RemoveHotlistEditorsRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Gathers all viewable hotlists that a user is a member of.
+	//
+	// Raises:
+	//   NOT_FOUND if the user is not found.
+	//   INVALID_ARGUMENT if the `user` is invalid.
+	GatherHotlistsForUser(context.Context, *GatherHotlistsForUserRequest) (*GatherHotlistsForUserResponse, error)
+}
+
+// UnimplementedHotlistsServer can be embedded to have forward compatible implementations.
+type UnimplementedHotlistsServer struct {
+}
+
+func (*UnimplementedHotlistsServer) CreateHotlist(context.Context, *CreateHotlistRequest) (*Hotlist, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method CreateHotlist not implemented")
+}
+func (*UnimplementedHotlistsServer) GetHotlist(context.Context, *GetHotlistRequest) (*Hotlist, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetHotlist not implemented")
+}
+func (*UnimplementedHotlistsServer) UpdateHotlist(context.Context, *UpdateHotlistRequest) (*Hotlist, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UpdateHotlist not implemented")
+}
+func (*UnimplementedHotlistsServer) DeleteHotlist(context.Context, *GetHotlistRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method DeleteHotlist not implemented")
+}
+func (*UnimplementedHotlistsServer) ListHotlistItems(context.Context, *ListHotlistItemsRequest) (*ListHotlistItemsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListHotlistItems not implemented")
+}
+func (*UnimplementedHotlistsServer) RerankHotlistItems(context.Context, *RerankHotlistItemsRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method RerankHotlistItems not implemented")
+}
+func (*UnimplementedHotlistsServer) AddHotlistItems(context.Context, *AddHotlistItemsRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method AddHotlistItems not implemented")
+}
+func (*UnimplementedHotlistsServer) RemoveHotlistItems(context.Context, *RemoveHotlistItemsRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method RemoveHotlistItems not implemented")
+}
+func (*UnimplementedHotlistsServer) RemoveHotlistEditors(context.Context, *RemoveHotlistEditorsRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method RemoveHotlistEditors not implemented")
+}
+func (*UnimplementedHotlistsServer) GatherHotlistsForUser(context.Context, *GatherHotlistsForUserRequest) (*GatherHotlistsForUserResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GatherHotlistsForUser not implemented")
+}
+
+func RegisterHotlistsServer(s prpc.Registrar, srv HotlistsServer) {
+	s.RegisterService(&_Hotlists_serviceDesc, srv)
+}
+
+func _Hotlists_CreateHotlist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(CreateHotlistRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).CreateHotlist(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/CreateHotlist",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).CreateHotlist(ctx, req.(*CreateHotlistRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_GetHotlist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetHotlistRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).GetHotlist(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/GetHotlist",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).GetHotlist(ctx, req.(*GetHotlistRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_UpdateHotlist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UpdateHotlistRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).UpdateHotlist(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/UpdateHotlist",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).UpdateHotlist(ctx, req.(*UpdateHotlistRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_DeleteHotlist_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetHotlistRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).DeleteHotlist(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/DeleteHotlist",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).DeleteHotlist(ctx, req.(*GetHotlistRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_ListHotlistItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListHotlistItemsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).ListHotlistItems(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/ListHotlistItems",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).ListHotlistItems(ctx, req.(*ListHotlistItemsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_RerankHotlistItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(RerankHotlistItemsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).RerankHotlistItems(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/RerankHotlistItems",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).RerankHotlistItems(ctx, req.(*RerankHotlistItemsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_AddHotlistItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(AddHotlistItemsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).AddHotlistItems(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/AddHotlistItems",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).AddHotlistItems(ctx, req.(*AddHotlistItemsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_RemoveHotlistItems_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(RemoveHotlistItemsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).RemoveHotlistItems(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/RemoveHotlistItems",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).RemoveHotlistItems(ctx, req.(*RemoveHotlistItemsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_RemoveHotlistEditors_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(RemoveHotlistEditorsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).RemoveHotlistEditors(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/RemoveHotlistEditors",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).RemoveHotlistEditors(ctx, req.(*RemoveHotlistEditorsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Hotlists_GatherHotlistsForUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GatherHotlistsForUserRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(HotlistsServer).GatherHotlistsForUser(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Hotlists/GatherHotlistsForUser",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(HotlistsServer).GatherHotlistsForUser(ctx, req.(*GatherHotlistsForUserRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Hotlists_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "monorail.v3.Hotlists",
+	HandlerType: (*HotlistsServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "CreateHotlist",
+			Handler:    _Hotlists_CreateHotlist_Handler,
+		},
+		{
+			MethodName: "GetHotlist",
+			Handler:    _Hotlists_GetHotlist_Handler,
+		},
+		{
+			MethodName: "UpdateHotlist",
+			Handler:    _Hotlists_UpdateHotlist_Handler,
+		},
+		{
+			MethodName: "DeleteHotlist",
+			Handler:    _Hotlists_DeleteHotlist_Handler,
+		},
+		{
+			MethodName: "ListHotlistItems",
+			Handler:    _Hotlists_ListHotlistItems_Handler,
+		},
+		{
+			MethodName: "RerankHotlistItems",
+			Handler:    _Hotlists_RerankHotlistItems_Handler,
+		},
+		{
+			MethodName: "AddHotlistItems",
+			Handler:    _Hotlists_AddHotlistItems_Handler,
+		},
+		{
+			MethodName: "RemoveHotlistItems",
+			Handler:    _Hotlists_RemoveHotlistItems_Handler,
+		},
+		{
+			MethodName: "RemoveHotlistEditors",
+			Handler:    _Hotlists_RemoveHotlistEditors_Handler,
+		},
+		{
+			MethodName: "GatherHotlistsForUser",
+			Handler:    _Hotlists_GatherHotlistsForUser_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/hotlists.proto",
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/hotlists.proto b/analysis/internal/bugs/monorail/api_proto/hotlists.proto
new file mode 100644
index 0000000..57e7828
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/hotlists.proto
@@ -0,0 +1,276 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/feature_objects.proto";
+import "google/protobuf/field_mask.proto";
+import "google/protobuf/empty.proto";
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+
+// ***ONLY CALL rpcs WITH `status: {ALPHA|STABLE}`***
+// rpcs without `status` are not ready.
+
+// Hotlists service includes all methods needed for managing Hotlists.
+service Hotlists {
+  // status: NOT READY
+  // Creates a new hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if some given hotlist editors do not exist.
+  //   ALREADY_EXISTS if a hotlist with the same name owned by the user
+  //   already exists.
+  //   INVALID_ARGUMENT if a `hotlist.owner` is given.
+  rpc CreateHotlist (CreateHotlistRequest) returns (Hotlist) {}
+
+  // status: NOT READY
+  // Returns the requested Hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the requested hotlist is not found.
+  //   PERMISSION_DENIED if the requester is not allowed to view the hotlist.
+  //   INVALID_ARGUMENT if the given resource name is not valid.
+  rpc GetHotlist (GetHotlistRequest) returns (Hotlist) {}
+
+  // status: NOT READY
+  // Updates a hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the hotlist is not found.
+  //   PERMISSION_DENIED if the requester is not allowed to update the hotlist.
+  //   INVALID_ARGUMENT if required fields are missing.
+  rpc UpdateHotlist (UpdateHotlistRequest) returns (Hotlist) {}
+
+  // status: NOT READY
+  // Deletes a hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the hotlist is not found.
+  //   PERMISSION_DENIED if the requester is not allowed to delete the hotlist.
+  rpc DeleteHotlist (GetHotlistRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Returns a list of all HotlistItems in the hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the parent hotlist is not found.
+  //   PERMISSION_DENIED if the requester is not allowed to view the hotlist.
+  //   INVALID_ARGUMENT if the page_token or given hotlist resource name is not
+  //   valid.
+  rpc ListHotlistItems (ListHotlistItemsRequest) returns (ListHotlistItemsResponse) {}
+
+  // status: NOT READY
+  // Reranks a hotlist's items.
+  //
+  // Raises:
+  //   NOT_FOUND if the hotlist or issues to rerank are not found.
+  //   PERMISSION_DENIED if the requester is not allowed to rerank the hotlist
+  //   or view issues they're trying to rerank.
+  //   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+  //   is empty or contains items not in the Hotlist.
+  rpc RerankHotlistItems (RerankHotlistItemsRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Adds new items associated with given issues to a hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the parent hotlist or issues are not found.
+  //   PERMISSION_DENIED if the requester is not allowed to edit the hotlist or
+  //   view issues they are trying to add.
+  //   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+  //   is empty or contains items not in the Hotlist.
+  rpc AddHotlistItems (AddHotlistItemsRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Removes items associated with given issues from a hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the parent hotlist or issues are not found.
+  //   PERMISSION_DENIED if the requester is not allowed to edit the hotlist or
+  //   view issues they are trying to remove.
+  //   INVALID_ARGUMENT if the `target_position` is invalid or `hotlist_items`
+  //   is empty or contains items not in the Hotlist.
+  rpc RemoveHotlistItems (RemoveHotlistItemsRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Removes editors assigned to a hotlist.
+  //
+  // Raises:
+  //   NOT_FOUND if the hotlist is not found.
+  //   PERMISSION_DENIED if the requester is not allowed to remove all specified
+  //   editors from the hotlist.
+  //   INVALID_ARGUMENT if any specified editors are not in the hotlist.
+  rpc RemoveHotlistEditors (RemoveHotlistEditorsRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Gathers all viewable hotlists that a user is a member of.
+  //
+  // Raises:
+  //   NOT_FOUND if the user is not found.
+  //   INVALID_ARGUMENT if the `user` is invalid.
+  rpc GatherHotlistsForUser (GatherHotlistsForUserRequest) returns (GatherHotlistsForUserResponse) {}
+}
+
+
+// Request message for CreateHotlist method.
+// Next available tag: 2
+message CreateHotlistRequest {
+  // The hotlist to create.
+  // `hotlist.owner` must be empty. The owner of the new hotlist will be
+  // set to the requester.
+  Hotlist hotlist = 1 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// Request message for GetHotlist method.
+// Next available tag: 2
+message GetHotlistRequest {
+  // The name of the hotlist to retrieve.
+  string name = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"}];
+}
+
+
+// Request message for UpdateHotlist method.
+// Next available tag: 2
+message UpdateHotlistRequest {
+  // The hotlist's `name` field is used to identify the hotlist to be updated.
+  Hotlist hotlist = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"} ];
+  // The list of fields to be updated.
+  google.protobuf.FieldMask update_mask = 2 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// Request message for ListHotlistItems method.
+// Next available tag: 5
+message ListHotlistItemsRequest {
+  // The parent hotlist, which owns this collection of items.
+  string parent = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"} ];
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  // If unspecified, at most 1000 items will be returned.
+  // The maximum value is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 2;
+  // The string of comma separated field names used to order the items.
+  // Adding '-' before a field, reverses the sort order.
+  // E.g. 'stars,-status' sorts the items by number of stars low to high, then
+  // status high to low.
+  // If unspecified, items will be ordered by their rank in the parent.
+  string order_by = 3;
+  // A page token, received from a previous `ListHotlistItems` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListHotlistItems` must
+  // match the call that provided the page token.
+  string page_token = 4;
+}
+
+
+// Response to ListHotlistItems call.
+// Next available tag: 3
+message ListHotlistItemsResponse {
+  // The items from the specified hotlist.
+  repeated HotlistItem items = 1;
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+
+// The request used to rerank a Hotlist.
+// Next available tag: 4
+message RerankHotlistItemsRequest {
+  // Resource name of the Hotlist to rerank.
+  string name = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"},
+      (google.api.field_behavior) = REQUIRED ];
+  // HotlistItems to be moved. The order of `hotlist_items` will
+  // determine the order of these items after they have been moved.
+  // E.g. With items [a, b, c, d, e], moving [d, c] to `target_position` 3, will
+  // result in items [a, b, e, d, c].
+  repeated string hotlist_items = 2 [
+      (google.api.resource_reference) = {type: "api.crbug.com/HotlistItem"},
+      (google.api.field_behavior) = REQUIRED ];
+  // Target starting position of the moved items.
+  // `target_position` must be between 0 and (# hotlist items - # items being moved).
+  uint32 target_position = 3 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// Request message for an AddHotlistItems call.
+// Next available tag: 4
+message AddHotlistItemsRequest {
+  // Resource name of the Hotlist to add new items to.
+  string parent = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"} ];
+  // Resource names of Issues to associate with new HotlistItems added to `parent`.
+  repeated string issues = 2 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Issue"} ];
+  // Target starting position of the new items.
+  // `target_position` must be between [0 and # of items that currently exist in
+  // `parent`]. The request will fail if a specified `target_position` is outside
+  // of this range.
+  // New HotlistItems added to a non-last position of the hotlist will
+  // cause ranks of existing HotlistItems below `target_position` to be adjusted.
+  // If no `target_position` is given, new items will be added to the end of
+  // `parent`.
+  uint32 target_position = 3;
+}
+
+
+// Request message for a RemoveHotlistItems call.
+// Next available tag: 3
+message RemoveHotlistItemsRequest {
+  // Resource name of the Hotlist to remove items from.
+  string parent = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"} ];
+  // Resource names of Issues associated with HotlistItems that should be removed.
+  repeated string issues = 2 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Issue"} ];
+}
+
+
+// Request message for a RemoveHotlistEditors call.
+// Next available tag: 3
+message RemoveHotlistEditorsRequest {
+  // Resource name of the Hotlist to remove editors from.
+  string name = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/Hotlist"} ];
+  // Resource names of Users associated with the hotlist that should be removed.
+  repeated string editors = 2 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/User"} ];
+}
+
+
+// Request message for a GatherHotlistsForUser call.
+// Next available tag: 2
+message GatherHotlistsForUserRequest {
+  // Resource name of the user whose hotlists we want to fetch.
+  string user = 1 [ (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/User"} ];
+}
+
+
+// Response message for a GatherHotlistsForUser call.
+// Next available tag: 2
+message GatherHotlistsForUserResponse {
+  repeated Hotlist hotlists = 1;
+}
\ No newline at end of file
diff --git a/analysis/internal/bugs/monorail/api_proto/issue_objects.pb.go b/analysis/internal/bugs/monorail/api_proto/issue_objects.pb.go
new file mode 100644
index 0000000..ceeeabc
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/issue_objects.pb.go
@@ -0,0 +1,1908 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for issues and related business
+// objects, e.g., field values, comments, and attachments.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/issue_objects.proto
+
+package api_proto
+
+import (
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Many values on an issue can be set either explicitly or by a rule.
+//
+// Note: Though Derivations are used as OUTPUT_ONLY, values including them
+// will still be ingested even though the Derivation is ignored.
+//
+// Next available tag: 3
+type Derivation int32
+
+const (
+	// The default derivation. This value is used if the derivation is omitted.
+	Derivation_DERIVATION_UNSPECIFIED Derivation = 0
+	// The value was explicitly set on the issue.
+	Derivation_EXPLICIT Derivation = 1
+	// Value was auto-applied to the issue based on a project's rule. See
+	// monorail/doc/userguide/project-owners.md#how-to-configure-filter-rules
+	Derivation_RULE Derivation = 2
+)
+
+// Enum value maps for Derivation.
+var (
+	Derivation_name = map[int32]string{
+		0: "DERIVATION_UNSPECIFIED",
+		1: "EXPLICIT",
+		2: "RULE",
+	}
+	Derivation_value = map[string]int32{
+		"DERIVATION_UNSPECIFIED": 0,
+		"EXPLICIT":               1,
+		"RULE":                   2,
+	}
+)
+
+func (x Derivation) Enum() *Derivation {
+	p := new(Derivation)
+	*p = x
+	return p
+}
+
+func (x Derivation) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Derivation) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[0].Descriptor()
+}
+
+func (Derivation) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[0]
+}
+
+func (x Derivation) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Derivation.Descriptor instead.
+func (Derivation) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{0}
+}
+
+// States that an issue or its comments can be in (aip.dev/216).
+// Next available tag: 4
+type IssueContentState int32
+
+const (
+	// The default value. This value is used if the state is omitted.
+	IssueContentState_STATE_UNSPECIFIED IssueContentState = 0
+	// The Issue or Comment is available.
+	IssueContentState_ACTIVE IssueContentState = 1
+	// The Issue or Comment has been deleted.
+	IssueContentState_DELETED IssueContentState = 2
+	// The Issue or Comment has been flagged as spam.
+	// Takes precedent over DELETED.
+	IssueContentState_SPAM IssueContentState = 3
+)
+
+// Enum value maps for IssueContentState.
+var (
+	IssueContentState_name = map[int32]string{
+		0: "STATE_UNSPECIFIED",
+		1: "ACTIVE",
+		2: "DELETED",
+		3: "SPAM",
+	}
+	IssueContentState_value = map[string]int32{
+		"STATE_UNSPECIFIED": 0,
+		"ACTIVE":            1,
+		"DELETED":           2,
+		"SPAM":              3,
+	}
+)
+
+func (x IssueContentState) Enum() *IssueContentState {
+	p := new(IssueContentState)
+	*p = x
+	return p
+}
+
+func (x IssueContentState) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (IssueContentState) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[1].Descriptor()
+}
+
+func (IssueContentState) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[1]
+}
+
+func (x IssueContentState) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use IssueContentState.Descriptor instead.
+func (IssueContentState) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{1}
+}
+
+// The type of comment.
+// Next available tag: 9
+type Comment_Type int32
+
+const (
+	// The default comment type. Used if type is omitted.
+	Comment_UNSPECIFIED Comment_Type = 0
+	// A standard comment on an issue.
+	Comment_COMMENT Comment_Type = 1
+	// A comment representing a new description for the issue.
+	Comment_DESCRIPTION Comment_Type = 2
+)
+
+// Enum value maps for Comment_Type.
+var (
+	Comment_Type_name = map[int32]string{
+		0: "UNSPECIFIED",
+		1: "COMMENT",
+		2: "DESCRIPTION",
+	}
+	Comment_Type_value = map[string]int32{
+		"UNSPECIFIED": 0,
+		"COMMENT":     1,
+		"DESCRIPTION": 2,
+	}
+)
+
+func (x Comment_Type) Enum() *Comment_Type {
+	p := new(Comment_Type)
+	*p = x
+	return p
+}
+
+func (x Comment_Type) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Comment_Type) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[2].Descriptor()
+}
+
+func (Comment_Type) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[2]
+}
+
+func (x Comment_Type) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Comment_Type.Descriptor instead.
+func (Comment_Type) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{0, 0}
+}
+
+// Potential states for an approval. Note that these statuses cause different
+// sets of notifications. See monorail/doc/userguide/email.md
+// Next available tag: 9
+type ApprovalValue_ApprovalStatus int32
+
+const (
+	// The default approval status. This value is used if the status is omitted.
+	ApprovalValue_APPROVAL_STATUS_UNSPECIFIED ApprovalValue_ApprovalStatus = 0
+	// No status has yet been set on this value.
+	ApprovalValue_NOT_SET ApprovalValue_ApprovalStatus = 1
+	// This issue needs review from the approvers for this phase.
+	ApprovalValue_NEEDS_REVIEW ApprovalValue_ApprovalStatus = 2
+	// This approval is not needed for this issue for this phase.
+	ApprovalValue_NA ApprovalValue_ApprovalStatus = 3
+	// The issue is ready for the approvers to review.
+	ApprovalValue_REVIEW_REQUESTED ApprovalValue_ApprovalStatus = 4
+	// The approvers have started reviewing this issue.
+	ApprovalValue_REVIEW_STARTED ApprovalValue_ApprovalStatus = 5
+	// The approvers need more information.
+	ApprovalValue_NEED_INFO ApprovalValue_ApprovalStatus = 6
+	// The approvers have approved this issue for this phase.
+	ApprovalValue_APPROVED ApprovalValue_ApprovalStatus = 7
+	// The approvers have indicated this issue is not approved for this phase.
+	ApprovalValue_NOT_APPROVED ApprovalValue_ApprovalStatus = 8
+)
+
+// Enum value maps for ApprovalValue_ApprovalStatus.
+var (
+	ApprovalValue_ApprovalStatus_name = map[int32]string{
+		0: "APPROVAL_STATUS_UNSPECIFIED",
+		1: "NOT_SET",
+		2: "NEEDS_REVIEW",
+		3: "NA",
+		4: "REVIEW_REQUESTED",
+		5: "REVIEW_STARTED",
+		6: "NEED_INFO",
+		7: "APPROVED",
+		8: "NOT_APPROVED",
+	}
+	ApprovalValue_ApprovalStatus_value = map[string]int32{
+		"APPROVAL_STATUS_UNSPECIFIED": 0,
+		"NOT_SET":                     1,
+		"NEEDS_REVIEW":                2,
+		"NA":                          3,
+		"REVIEW_REQUESTED":            4,
+		"REVIEW_STARTED":              5,
+		"NEED_INFO":                   6,
+		"APPROVED":                    7,
+		"NOT_APPROVED":                8,
+	}
+)
+
+func (x ApprovalValue_ApprovalStatus) Enum() *ApprovalValue_ApprovalStatus {
+	p := new(ApprovalValue_ApprovalStatus)
+	*p = x
+	return p
+}
+
+func (x ApprovalValue_ApprovalStatus) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ApprovalValue_ApprovalStatus) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[3].Descriptor()
+}
+
+func (ApprovalValue_ApprovalStatus) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes[3]
+}
+
+func (x ApprovalValue_ApprovalStatus) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ApprovalValue_ApprovalStatus.Descriptor instead.
+func (ApprovalValue_ApprovalStatus) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{5, 0}
+}
+
+// Represents a comment and any associated changes to an Issue.
+//
+// Comments cannot be Created or Updated through standard methods. The
+// OUTPUT_ONLY annotations here indicate fields that would never be provided
+// by the user even if these methods were made available.
+// Next available tag: 11.
+type Comment struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the comment.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The state of the comment.
+	State IssueContentState `protobuf:"varint,2,opt,name=state,proto3,enum=monorail.v3.IssueContentState" json:"state,omitempty"`
+	// The type of comment.
+	Type Comment_Type `protobuf:"varint,3,opt,name=type,proto3,enum=monorail.v3.Comment_Type" json:"type,omitempty"`
+	// The text of the comment.
+	Content string `protobuf:"bytes,4,opt,name=content,proto3" json:"content,omitempty"`
+	// Resource name of the author of the comment.
+	Commenter string `protobuf:"bytes,5,opt,name=commenter,proto3" json:"commenter,omitempty"`
+	// The time this comment was added to the Issue.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+	// Optional string full text of an email that caused this comment to be added.
+	InboundMessage string `protobuf:"bytes,7,opt,name=inbound_message,json=inboundMessage,proto3" json:"inbound_message,omitempty"`
+	// The approval this comment is associated with, if applicable.
+	Approval string `protobuf:"bytes,8,opt,name=approval,proto3" json:"approval,omitempty"`
+	// Any changes made to the issue in association with this comment.
+	Amendments []*Comment_Amendment `protobuf:"bytes,9,rep,name=amendments,proto3" json:"amendments,omitempty"`
+	// Any attachments uploaded in association with this comment.
+	Attachments []*Comment_Attachment `protobuf:"bytes,10,rep,name=attachments,proto3" json:"attachments,omitempty"`
+}
+
+func (x *Comment) Reset() {
+	*x = Comment{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Comment) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Comment) ProtoMessage() {}
+
+func (x *Comment) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Comment.ProtoReflect.Descriptor instead.
+func (*Comment) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Comment) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Comment) GetState() IssueContentState {
+	if x != nil {
+		return x.State
+	}
+	return IssueContentState_STATE_UNSPECIFIED
+}
+
+func (x *Comment) GetType() Comment_Type {
+	if x != nil {
+		return x.Type
+	}
+	return Comment_UNSPECIFIED
+}
+
+func (x *Comment) GetContent() string {
+	if x != nil {
+		return x.Content
+	}
+	return ""
+}
+
+func (x *Comment) GetCommenter() string {
+	if x != nil {
+		return x.Commenter
+	}
+	return ""
+}
+
+func (x *Comment) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+func (x *Comment) GetInboundMessage() string {
+	if x != nil {
+		return x.InboundMessage
+	}
+	return ""
+}
+
+func (x *Comment) GetApproval() string {
+	if x != nil {
+		return x.Approval
+	}
+	return ""
+}
+
+func (x *Comment) GetAmendments() []*Comment_Amendment {
+	if x != nil {
+		return x.Amendments
+	}
+	return nil
+}
+
+func (x *Comment) GetAttachments() []*Comment_Attachment {
+	if x != nil {
+		return x.Attachments
+	}
+	return nil
+}
+
+// A value of a custom field for an issue.
+// Next available tag: 5
+type FieldValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The project-defined field associated with this value
+	Field string `protobuf:"bytes,1,opt,name=field,proto3" json:"field,omitempty"`
+	// The value associated with the field.
+	// Mapping of field types to string value:
+	// ENUM_TYPE(int) => str(value)
+	// INT_TYPE(int) => str(value)
+	// STR_TYPE(str) => value
+	// USER_TYPE(int) => the user's resource name
+	// DATE_TYPE(int) => str(int) representing time in seconds since epoch
+	// URL_TYPE(str) => value
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+	// How the value was derived.
+	Derivation Derivation `protobuf:"varint,3,opt,name=derivation,proto3,enum=monorail.v3.Derivation" json:"derivation,omitempty"`
+	// Issues with phase-specific fields can have values for each phase.
+	Phase string `protobuf:"bytes,4,opt,name=phase,proto3" json:"phase,omitempty"`
+}
+
+func (x *FieldValue) Reset() {
+	*x = FieldValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldValue) ProtoMessage() {}
+
+func (x *FieldValue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldValue.ProtoReflect.Descriptor instead.
+func (*FieldValue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *FieldValue) GetField() string {
+	if x != nil {
+		return x.Field
+	}
+	return ""
+}
+
+func (x *FieldValue) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+func (x *FieldValue) GetDerivation() Derivation {
+	if x != nil {
+		return x.Derivation
+	}
+	return Derivation_DERIVATION_UNSPECIFIED
+}
+
+func (x *FieldValue) GetPhase() string {
+	if x != nil {
+		return x.Phase
+	}
+	return ""
+}
+
+// Documents and tracks a bug, task, or feature request within a Project.
+// Next available tag: 23
+type Issue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the issue.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// A brief summary of the issue. Generally displayed as a user-facing title.
+	// TODO(monorail:6988): The UI limits summary length while the backend does
+	// not. Resolve this discrepancy.
+	Summary string `protobuf:"bytes,2,opt,name=summary,proto3" json:"summary,omitempty"`
+	// The state of the issue.
+	State IssueContentState `protobuf:"varint,3,opt,name=state,proto3,enum=monorail.v3.IssueContentState" json:"state,omitempty"`
+	// The current status of the issue.
+	Status *Issue_StatusValue `protobuf:"bytes,4,opt,name=status,proto3" json:"status,omitempty"`
+	// The user who created the issue.
+	Reporter string `protobuf:"bytes,5,opt,name=reporter,proto3" json:"reporter,omitempty"`
+	// The user currently responsible for the issue. This user must be a member of
+	// the Project.
+	Owner *Issue_UserValue `protobuf:"bytes,6,opt,name=owner,proto3" json:"owner,omitempty"`
+	// Additional users receiving notifications on the issue.
+	CcUsers []*Issue_UserValue `protobuf:"bytes,7,rep,name=cc_users,json=ccUsers,proto3" json:"cc_users,omitempty"`
+	// Labels applied to the issue.
+	Labels []*Issue_LabelValue `protobuf:"bytes,8,rep,name=labels,proto3" json:"labels,omitempty"`
+	// Components the issue is associated with.
+	Components []*Issue_ComponentValue `protobuf:"bytes,9,rep,name=components,proto3" json:"components,omitempty"`
+	// Values for custom fields on the issue.
+	FieldValues []*FieldValue `protobuf:"bytes,10,rep,name=field_values,json=fieldValues,proto3" json:"field_values,omitempty"`
+	// An issue can be merged into another. If this value is set, the issue
+	// referred to should be considered the primary source for further updates.
+	MergedIntoIssueRef *IssueRef `protobuf:"bytes,11,opt,name=merged_into_issue_ref,json=mergedIntoIssueRef,proto3" json:"merged_into_issue_ref,omitempty"`
+	// Issues preventing the completion of this issue.
+	BlockedOnIssueRefs []*IssueRef `protobuf:"bytes,12,rep,name=blocked_on_issue_refs,json=blockedOnIssueRefs,proto3" json:"blocked_on_issue_refs,omitempty"`
+	// Issues for which this issue is blocking completion.
+	BlockingIssueRefs []*IssueRef `protobuf:"bytes,13,rep,name=blocking_issue_refs,json=blockingIssueRefs,proto3" json:"blocking_issue_refs,omitempty"`
+	// The time the issue was reported.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,14,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+	// The most recent time the issue was closed.
+	CloseTime *timestamppb.Timestamp `protobuf:"bytes,15,opt,name=close_time,json=closeTime,proto3" json:"close_time,omitempty"`
+	// The most recent time the issue was modified.
+	ModifyTime *timestamppb.Timestamp `protobuf:"bytes,16,opt,name=modify_time,json=modifyTime,proto3" json:"modify_time,omitempty"`
+	// The most recent time a component value was modified.
+	ComponentModifyTime *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=component_modify_time,json=componentModifyTime,proto3" json:"component_modify_time,omitempty"`
+	// The most recent time the status value was modified.
+	StatusModifyTime *timestamppb.Timestamp `protobuf:"bytes,18,opt,name=status_modify_time,json=statusModifyTime,proto3" json:"status_modify_time,omitempty"`
+	// The most recent time the owner made a modification to the issue.
+	OwnerModifyTime *timestamppb.Timestamp `protobuf:"bytes,19,opt,name=owner_modify_time,json=ownerModifyTime,proto3" json:"owner_modify_time,omitempty"`
+	// The number of attachments associated with the issue.
+	AttachmentCount uint32 `protobuf:"varint,20,opt,name=attachment_count,json=attachmentCount,proto3" json:"attachment_count,omitempty"`
+	// The number of users who have starred the issue.
+	StarCount uint32 `protobuf:"varint,21,opt,name=star_count,json=starCount,proto3" json:"star_count,omitempty"`
+	// Phases of a process the issue is tracking (if applicable).
+	// See monorail/doc/userguide/concepts.md#issue-approvals-and-gates
+	Phases []string `protobuf:"bytes,22,rep,name=phases,proto3" json:"phases,omitempty"`
+}
+
+func (x *Issue) Reset() {
+	*x = Issue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Issue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Issue) ProtoMessage() {}
+
+func (x *Issue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Issue.ProtoReflect.Descriptor instead.
+func (*Issue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *Issue) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Issue) GetSummary() string {
+	if x != nil {
+		return x.Summary
+	}
+	return ""
+}
+
+func (x *Issue) GetState() IssueContentState {
+	if x != nil {
+		return x.State
+	}
+	return IssueContentState_STATE_UNSPECIFIED
+}
+
+func (x *Issue) GetStatus() *Issue_StatusValue {
+	if x != nil {
+		return x.Status
+	}
+	return nil
+}
+
+func (x *Issue) GetReporter() string {
+	if x != nil {
+		return x.Reporter
+	}
+	return ""
+}
+
+func (x *Issue) GetOwner() *Issue_UserValue {
+	if x != nil {
+		return x.Owner
+	}
+	return nil
+}
+
+func (x *Issue) GetCcUsers() []*Issue_UserValue {
+	if x != nil {
+		return x.CcUsers
+	}
+	return nil
+}
+
+func (x *Issue) GetLabels() []*Issue_LabelValue {
+	if x != nil {
+		return x.Labels
+	}
+	return nil
+}
+
+func (x *Issue) GetComponents() []*Issue_ComponentValue {
+	if x != nil {
+		return x.Components
+	}
+	return nil
+}
+
+func (x *Issue) GetFieldValues() []*FieldValue {
+	if x != nil {
+		return x.FieldValues
+	}
+	return nil
+}
+
+func (x *Issue) GetMergedIntoIssueRef() *IssueRef {
+	if x != nil {
+		return x.MergedIntoIssueRef
+	}
+	return nil
+}
+
+func (x *Issue) GetBlockedOnIssueRefs() []*IssueRef {
+	if x != nil {
+		return x.BlockedOnIssueRefs
+	}
+	return nil
+}
+
+func (x *Issue) GetBlockingIssueRefs() []*IssueRef {
+	if x != nil {
+		return x.BlockingIssueRefs
+	}
+	return nil
+}
+
+func (x *Issue) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+func (x *Issue) GetCloseTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CloseTime
+	}
+	return nil
+}
+
+func (x *Issue) GetModifyTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.ModifyTime
+	}
+	return nil
+}
+
+func (x *Issue) GetComponentModifyTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.ComponentModifyTime
+	}
+	return nil
+}
+
+func (x *Issue) GetStatusModifyTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.StatusModifyTime
+	}
+	return nil
+}
+
+func (x *Issue) GetOwnerModifyTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.OwnerModifyTime
+	}
+	return nil
+}
+
+func (x *Issue) GetAttachmentCount() uint32 {
+	if x != nil {
+		return x.AttachmentCount
+	}
+	return 0
+}
+
+func (x *Issue) GetStarCount() uint32 {
+	if x != nil {
+		return x.StarCount
+	}
+	return 0
+}
+
+func (x *Issue) GetPhases() []string {
+	if x != nil {
+		return x.Phases
+	}
+	return nil
+}
+
+// Specifies a column in an issues list view.
+// Next available tag: 2
+type IssuesListColumn struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Column name shown in the column header.
+	Column string `protobuf:"bytes,1,opt,name=column,proto3" json:"column,omitempty"`
+}
+
+func (x *IssuesListColumn) Reset() {
+	*x = IssuesListColumn{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IssuesListColumn) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IssuesListColumn) ProtoMessage() {}
+
+func (x *IssuesListColumn) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IssuesListColumn.ProtoReflect.Descriptor instead.
+func (*IssuesListColumn) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *IssuesListColumn) GetColumn() string {
+	if x != nil {
+		return x.Column
+	}
+	return ""
+}
+
+// Refers to an issue that may or may not be tracked in Monorail.
+// At least one of `issue` and `ext_identifier` MUST be set; they MUST NOT both
+// be set.
+// Next available tag: 3
+type IssueRef struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of an issue tracked in Monorail
+	Issue string `protobuf:"bytes,1,opt,name=issue,proto3" json:"issue,omitempty"`
+	// For referencing external issues, e.g. b/1234, or a dangling reference
+	// to an old 'codesite' issue.
+	// TODO(monorail:7208): add more documentation on dangling references.
+	ExtIdentifier string `protobuf:"bytes,2,opt,name=ext_identifier,json=extIdentifier,proto3" json:"ext_identifier,omitempty"`
+}
+
+func (x *IssueRef) Reset() {
+	*x = IssueRef{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IssueRef) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IssueRef) ProtoMessage() {}
+
+func (x *IssueRef) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IssueRef.ProtoReflect.Descriptor instead.
+func (*IssueRef) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *IssueRef) GetIssue() string {
+	if x != nil {
+		return x.Issue
+	}
+	return ""
+}
+
+func (x *IssueRef) GetExtIdentifier() string {
+	if x != nil {
+		return x.ExtIdentifier
+	}
+	return ""
+}
+
+// Documents and tracks an approval process.
+// See monorail/doc/userguide/concepts.md#issue-approvals-and-gates
+// Next available tag: 9
+type ApprovalValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The resource name of the ApprovalDef.
+	ApprovalDef string `protobuf:"bytes,2,opt,name=approval_def,json=approvalDef,proto3" json:"approval_def,omitempty"`
+	// The users able to grant this approval.
+	Approvers []string `protobuf:"bytes,3,rep,name=approvers,proto3" json:"approvers,omitempty"`
+	// The current status of the approval.
+	Status ApprovalValue_ApprovalStatus `protobuf:"varint,4,opt,name=status,proto3,enum=monorail.v3.ApprovalValue_ApprovalStatus" json:"status,omitempty"`
+	// The time `status` was last set.
+	SetTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=set_time,json=setTime,proto3" json:"set_time,omitempty"`
+	// The user who most recently set `status`.
+	Setter string `protobuf:"bytes,6,opt,name=setter,proto3" json:"setter,omitempty"`
+	// The phase the approval is associated with (if applicable).
+	Phase string `protobuf:"bytes,7,opt,name=phase,proto3" json:"phase,omitempty"`
+	// FieldValues with `approval_def` as their parent.
+	FieldValues []*FieldValue `protobuf:"bytes,8,rep,name=field_values,json=fieldValues,proto3" json:"field_values,omitempty"`
+}
+
+func (x *ApprovalValue) Reset() {
+	*x = ApprovalValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ApprovalValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ApprovalValue) ProtoMessage() {}
+
+func (x *ApprovalValue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ApprovalValue.ProtoReflect.Descriptor instead.
+func (*ApprovalValue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ApprovalValue) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ApprovalValue) GetApprovalDef() string {
+	if x != nil {
+		return x.ApprovalDef
+	}
+	return ""
+}
+
+func (x *ApprovalValue) GetApprovers() []string {
+	if x != nil {
+		return x.Approvers
+	}
+	return nil
+}
+
+func (x *ApprovalValue) GetStatus() ApprovalValue_ApprovalStatus {
+	if x != nil {
+		return x.Status
+	}
+	return ApprovalValue_APPROVAL_STATUS_UNSPECIFIED
+}
+
+func (x *ApprovalValue) GetSetTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.SetTime
+	}
+	return nil
+}
+
+func (x *ApprovalValue) GetSetter() string {
+	if x != nil {
+		return x.Setter
+	}
+	return ""
+}
+
+func (x *ApprovalValue) GetPhase() string {
+	if x != nil {
+		return x.Phase
+	}
+	return ""
+}
+
+func (x *ApprovalValue) GetFieldValues() []*FieldValue {
+	if x != nil {
+		return x.FieldValues
+	}
+	return nil
+}
+
+// A file attached to a comment.
+// Next available tag: 8
+type Comment_Attachment struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the attached file.
+	Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"`
+	// It is possible for attachments to be deleted (and undeleted) by the
+	// uploader. The name of deleted attachments are still shown, but the
+	// content is not available.
+	State IssueContentState `protobuf:"varint,2,opt,name=state,proto3,enum=monorail.v3.IssueContentState" json:"state,omitempty"`
+	// Size of the attached file in bytes.
+	Size uint64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"`
+	// The type of content contained in the file, using the IANA's media type
+	// https://www.iana.org/assignments/media-types/media-types.xhtml.
+	MediaType string `protobuf:"bytes,4,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"`
+	// The URI used for a preview of the attachment (when relelvant).
+	ThumbnailUri string `protobuf:"bytes,5,opt,name=thumbnail_uri,json=thumbnailUri,proto3" json:"thumbnail_uri,omitempty"`
+	// The URI used to view the content of the attachment.
+	ViewUri string `protobuf:"bytes,6,opt,name=view_uri,json=viewUri,proto3" json:"view_uri,omitempty"`
+	// The URI used to download the content of the attachment.
+	DownloadUri string `protobuf:"bytes,7,opt,name=download_uri,json=downloadUri,proto3" json:"download_uri,omitempty"`
+}
+
+func (x *Comment_Attachment) Reset() {
+	*x = Comment_Attachment{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Comment_Attachment) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Comment_Attachment) ProtoMessage() {}
+
+func (x *Comment_Attachment) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Comment_Attachment.ProtoReflect.Descriptor instead.
+func (*Comment_Attachment) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{0, 0}
+}
+
+func (x *Comment_Attachment) GetFilename() string {
+	if x != nil {
+		return x.Filename
+	}
+	return ""
+}
+
+func (x *Comment_Attachment) GetState() IssueContentState {
+	if x != nil {
+		return x.State
+	}
+	return IssueContentState_STATE_UNSPECIFIED
+}
+
+func (x *Comment_Attachment) GetSize() uint64 {
+	if x != nil {
+		return x.Size
+	}
+	return 0
+}
+
+func (x *Comment_Attachment) GetMediaType() string {
+	if x != nil {
+		return x.MediaType
+	}
+	return ""
+}
+
+func (x *Comment_Attachment) GetThumbnailUri() string {
+	if x != nil {
+		return x.ThumbnailUri
+	}
+	return ""
+}
+
+func (x *Comment_Attachment) GetViewUri() string {
+	if x != nil {
+		return x.ViewUri
+	}
+	return ""
+}
+
+func (x *Comment_Attachment) GetDownloadUri() string {
+	if x != nil {
+		return x.DownloadUri
+	}
+	return ""
+}
+
+// This message is only suitable for displaying the amendment to users.
+// We don't currently offer structured amendments that client code can
+// reason about, field names can be ambiguous, and we don't have
+// old_value for most changes.
+// Next available tag: 4
+type Comment_Amendment struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// This may be the name of a built-in or custom field, or relative to
+	// an approval field name.
+	FieldName string `protobuf:"bytes,1,opt,name=field_name,json=fieldName,proto3" json:"field_name,omitempty"`
+	// This may be a new value that overwrote the old value, e.g., "Assigned",
+	// or it may be a space-separated list of changes, e.g., "Size-L -Size-S".
+	NewOrDeltaValue string `protobuf:"bytes,2,opt,name=new_or_delta_value,json=newOrDeltaValue,proto3" json:"new_or_delta_value,omitempty"`
+	// old_value is only used when the user changes the summary.
+	OldValue string `protobuf:"bytes,3,opt,name=old_value,json=oldValue,proto3" json:"old_value,omitempty"`
+}
+
+func (x *Comment_Amendment) Reset() {
+	*x = Comment_Amendment{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Comment_Amendment) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Comment_Amendment) ProtoMessage() {}
+
+func (x *Comment_Amendment) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Comment_Amendment.ProtoReflect.Descriptor instead.
+func (*Comment_Amendment) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{0, 1}
+}
+
+func (x *Comment_Amendment) GetFieldName() string {
+	if x != nil {
+		return x.FieldName
+	}
+	return ""
+}
+
+func (x *Comment_Amendment) GetNewOrDeltaValue() string {
+	if x != nil {
+		return x.NewOrDeltaValue
+	}
+	return ""
+}
+
+func (x *Comment_Amendment) GetOldValue() string {
+	if x != nil {
+		return x.OldValue
+	}
+	return ""
+}
+
+// A possibly rule-derived component for the issue.
+// Next available tag: 3
+type Issue_ComponentValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// AIP resource name of the component.
+	Component string `protobuf:"bytes,1,opt,name=component,proto3" json:"component,omitempty"`
+	// How the component was derived.
+	Derivation Derivation `protobuf:"varint,2,opt,name=derivation,proto3,enum=monorail.v3.Derivation" json:"derivation,omitempty"`
+}
+
+func (x *Issue_ComponentValue) Reset() {
+	*x = Issue_ComponentValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Issue_ComponentValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Issue_ComponentValue) ProtoMessage() {}
+
+func (x *Issue_ComponentValue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Issue_ComponentValue.ProtoReflect.Descriptor instead.
+func (*Issue_ComponentValue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{2, 0}
+}
+
+func (x *Issue_ComponentValue) GetComponent() string {
+	if x != nil {
+		return x.Component
+	}
+	return ""
+}
+
+func (x *Issue_ComponentValue) GetDerivation() Derivation {
+	if x != nil {
+		return x.Derivation
+	}
+	return Derivation_DERIVATION_UNSPECIFIED
+}
+
+// A possibly rule-derived label for an issue.
+// Next available tag: 3
+type Issue_LabelValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The label.
+	Label string `protobuf:"bytes,1,opt,name=label,proto3" json:"label,omitempty"`
+	// How the label was derived.
+	Derivation Derivation `protobuf:"varint,2,opt,name=derivation,proto3,enum=monorail.v3.Derivation" json:"derivation,omitempty"`
+}
+
+func (x *Issue_LabelValue) Reset() {
+	*x = Issue_LabelValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Issue_LabelValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Issue_LabelValue) ProtoMessage() {}
+
+func (x *Issue_LabelValue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Issue_LabelValue.ProtoReflect.Descriptor instead.
+func (*Issue_LabelValue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{2, 1}
+}
+
+func (x *Issue_LabelValue) GetLabel() string {
+	if x != nil {
+		return x.Label
+	}
+	return ""
+}
+
+func (x *Issue_LabelValue) GetDerivation() Derivation {
+	if x != nil {
+		return x.Derivation
+	}
+	return Derivation_DERIVATION_UNSPECIFIED
+}
+
+// A possibly rule-derived status for an issue.
+// Next available tag: 3
+type Issue_StatusValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The status of the issue. Note that in rare cases this can be a
+	// value not defined in the project's StatusDefs (e.g. if the issue
+	// was moved from another project).
+	Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"`
+	// How the status was derived.
+	Derivation Derivation `protobuf:"varint,2,opt,name=derivation,proto3,enum=monorail.v3.Derivation" json:"derivation,omitempty"`
+}
+
+func (x *Issue_StatusValue) Reset() {
+	*x = Issue_StatusValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[10]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Issue_StatusValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Issue_StatusValue) ProtoMessage() {}
+
+func (x *Issue_StatusValue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[10]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Issue_StatusValue.ProtoReflect.Descriptor instead.
+func (*Issue_StatusValue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{2, 2}
+}
+
+func (x *Issue_StatusValue) GetStatus() string {
+	if x != nil {
+		return x.Status
+	}
+	return ""
+}
+
+func (x *Issue_StatusValue) GetDerivation() Derivation {
+	if x != nil {
+		return x.Derivation
+	}
+	return Derivation_DERIVATION_UNSPECIFIED
+}
+
+// A possibly rule-derived user value on an issue.
+// Next available tag: 3
+type Issue_UserValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The user.
+	User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
+	// How the user value was derived.
+	Derivation Derivation `protobuf:"varint,2,opt,name=derivation,proto3,enum=monorail.v3.Derivation" json:"derivation,omitempty"`
+}
+
+func (x *Issue_UserValue) Reset() {
+	*x = Issue_UserValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[11]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Issue_UserValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Issue_UserValue) ProtoMessage() {}
+
+func (x *Issue_UserValue) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[11]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Issue_UserValue.ProtoReflect.Descriptor instead.
+func (*Issue_UserValue) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP(), []int{2, 3}
+}
+
+func (x *Issue_UserValue) GetUser() string {
+	if x != nil {
+		return x.User
+	}
+	return ""
+}
+
+func (x *Issue_UserValue) GetDerivation() Derivation {
+	if x != nil {
+		return x.Derivation
+	}
+	return Derivation_DERIVATION_UNSPECIFIED
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDesc = []byte{
+	0x0a, 0x52, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69,
+	0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72,
+	0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8c,
+	0x08, 0x0a, 0x07, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61,
+	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x39,
+	0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75,
+	0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x42, 0x03, 0xe0,
+	0x41, 0x03, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x2d, 0x0a, 0x04, 0x74, 0x79, 0x70,
+	0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x19, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79,
+	0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74,
+	0x65, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65,
+	0x6e, 0x74, 0x12, 0x38, 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e,
+	0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0xe0, 0x41,
+	0x03, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x65, 0x72, 0x12, 0x40, 0x0a, 0x0b,
+	0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x03, 0xe0,
+	0x41, 0x03, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x2c,
+	0x0a, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67,
+	0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0e, 0x69, 0x6e,
+	0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x3c, 0x0a, 0x08,
+	0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x20,
+	0xfa, 0x41, 0x1d, 0x0a, 0x1b, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63,
+	0x6f, 0x6d, 0x2f, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65,
+	0x52, 0x08, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x12, 0x43, 0x0a, 0x0a, 0x61, 0x6d,
+	0x65, 0x6e, 0x64, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d,
+	0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x6d, 0x65, 0x6e, 0x64, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x03,
+	0xe0, 0x41, 0x03, 0x52, 0x0a, 0x61, 0x6d, 0x65, 0x6e, 0x64, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12,
+	0x46, 0x0a, 0x0b, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x0a,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x74, 0x74, 0x61, 0x63,
+	0x68, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0b, 0x61, 0x74, 0x74, 0x61,
+	0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x1a, 0xf4, 0x01, 0x0a, 0x0a, 0x41, 0x74, 0x74, 0x61,
+	0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61,
+	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61,
+	0x6d, 0x65, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x0e, 0x32, 0x1e, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74,
+	0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x69, 0x7a, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x73, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a,
+	0x6d, 0x65, 0x64, 0x69, 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x09, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x74,
+	0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x05, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0c, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x55, 0x72, 0x69,
+	0x12, 0x19, 0x0a, 0x08, 0x76, 0x69, 0x65, 0x77, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x06, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x76, 0x69, 0x65, 0x77, 0x55, 0x72, 0x69, 0x12, 0x21, 0x0a, 0x0c, 0x64,
+	0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x5f, 0x75, 0x72, 0x69, 0x18, 0x07, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x55, 0x72, 0x69, 0x1a, 0x74,
+	0x0a, 0x09, 0x41, 0x6d, 0x65, 0x6e, 0x64, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x66,
+	0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x09, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x2b, 0x0a, 0x12, 0x6e, 0x65,
+	0x77, 0x5f, 0x6f, 0x72, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6e, 0x65, 0x77, 0x4f, 0x72, 0x44, 0x65, 0x6c,
+	0x74, 0x61, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6f, 0x6c, 0x64, 0x5f, 0x76,
+	0x61, 0x6c, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x6f, 0x6c, 0x64, 0x56,
+	0x61, 0x6c, 0x75, 0x65, 0x22, 0x35, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x0f, 0x0a, 0x0b,
+	0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0b, 0x0a,
+	0x07, 0x43, 0x4f, 0x4d, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x44, 0x45,
+	0x53, 0x43, 0x52, 0x49, 0x50, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x02, 0x3a, 0x50, 0xea, 0x41, 0x4d,
+	0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x12, 0x34, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f, 0x69, 0x73, 0x73, 0x75,
+	0x65, 0x73, 0x2f, 0x7b, 0x69, 0x73, 0x73, 0x75, 0x65, 0x7d, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x65,
+	0x6e, 0x74, 0x73, 0x2f, 0x7b, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x7d, 0x22, 0xa9, 0x01,
+	0x0a, 0x0a, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x31, 0x0a, 0x05,
+	0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1b, 0xfa, 0x41, 0x18,
+	0x0a, 0x16, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x52, 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x12,
+	0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+	0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x44, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x12, 0x14, 0x0a, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x22, 0x95, 0x0e, 0x0a, 0x05, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61,
+	0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72,
+	0x79, 0x12, 0x39, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e,
+	0x32, 0x1e, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65,
+	0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x3b, 0x0a, 0x06,
+	0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65,
+	0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x42, 0x03, 0xe0, 0x41,
+	0x02, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x36, 0x0a, 0x08, 0x72, 0x65, 0x70,
+	0x6f, 0x72, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14,
+	0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x03, 0x52, 0x08, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65,
+	0x72, 0x12, 0x32, 0x0a, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1c, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x05,
+	0x6f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x37, 0x0a, 0x08, 0x63, 0x63, 0x5f, 0x75, 0x73, 0x65, 0x72,
+	0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x2e, 0x55, 0x73, 0x65, 0x72,
+	0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x07, 0x63, 0x63, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x35,
+	0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73,
+	0x75, 0x65, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x06, 0x6c,
+	0x61, 0x62, 0x65, 0x6c, 0x73, 0x12, 0x41, 0x0a, 0x0a, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65,
+	0x6e, 0x74, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x2e, 0x43, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0a, 0x63, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x3a, 0x0a, 0x0c, 0x66, 0x69, 0x65, 0x6c,
+	0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65,
+	0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61,
+	0x6c, 0x75, 0x65, 0x73, 0x12, 0x48, 0x0a, 0x15, 0x6d, 0x65, 0x72, 0x67, 0x65, 0x64, 0x5f, 0x69,
+	0x6e, 0x74, 0x6f, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x0b, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x52, 0x12, 0x6d, 0x65, 0x72, 0x67,
+	0x65, 0x64, 0x49, 0x6e, 0x74, 0x6f, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x12, 0x48,
+	0x0a, 0x15, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x6f, 0x6e, 0x5f, 0x69, 0x73, 0x73,
+	0x75, 0x65, 0x5f, 0x72, 0x65, 0x66, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75,
+	0x65, 0x52, 0x65, 0x66, 0x52, 0x12, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x4f, 0x6e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x73, 0x12, 0x45, 0x0a, 0x13, 0x62, 0x6c, 0x6f, 0x63,
+	0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x72, 0x65, 0x66, 0x73, 0x18,
+	0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x52, 0x11, 0x62, 0x6c,
+	0x6f, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x73, 0x12,
+	0x40, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0e,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
+	0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d,
+	0x65, 0x12, 0x3e, 0x0a, 0x0a, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
+	0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+	0x70, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x09, 0x63, 0x6c, 0x6f, 0x73, 0x65, 0x54, 0x69, 0x6d,
+	0x65, 0x12, 0x40, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65,
+	0x18, 0x10, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x54,
+	0x69, 0x6d, 0x65, 0x12, 0x53, 0x0a, 0x15, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74,
+	0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x11, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x03,
+	0xe0, 0x41, 0x03, 0x52, 0x13, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4d, 0x6f,
+	0x64, 0x69, 0x66, 0x79, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x4d, 0x0a, 0x12, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x12,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
+	0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x10, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x4d, 0x6f, 0x64,
+	0x69, 0x66, 0x79, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x4b, 0x0a, 0x11, 0x6f, 0x77, 0x6e, 0x65, 0x72,
+	0x5f, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x13, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x03,
+	0xe0, 0x41, 0x03, 0x52, 0x0f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79,
+	0x54, 0x69, 0x6d, 0x65, 0x12, 0x2e, 0x0a, 0x10, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65,
+	0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0d, 0x42, 0x03,
+	0xe0, 0x41, 0x03, 0x52, 0x0f, 0x61, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x43,
+	0x6f, 0x75, 0x6e, 0x74, 0x12, 0x22, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x5f, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x18, 0x15, 0x20, 0x01, 0x28, 0x0d, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x09, 0x73,
+	0x74, 0x61, 0x72, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x06, 0x70, 0x68, 0x61, 0x73,
+	0x65, 0x73, 0x18, 0x16, 0x20, 0x03, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x06, 0x70,
+	0x68, 0x61, 0x73, 0x65, 0x73, 0x1a, 0x8d, 0x01, 0x0a, 0x0e, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e,
+	0x65, 0x6e, 0x74, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x3d, 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x70,
+	0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1f, 0xfa, 0x41, 0x1c,
+	0x0a, 0x1a, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x09, 0x63, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x44, 0x65, 0x72, 0x69, 0x76, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x60, 0x0a, 0x0a, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x56, 0x61,
+	0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x72,
+	0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x44, 0x65, 0x72, 0x69,
+	0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x64, 0x65, 0x72,
+	0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x63, 0x0a, 0x0b, 0x53, 0x74, 0x61, 0x74, 0x75,
+	0x73, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3c,
+	0x0a, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x44, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x03, 0xe0, 0x41, 0x03,
+	0x52, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x1a, 0x76, 0x0a, 0x09,
+	0x55, 0x73, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x2b, 0x0a, 0x04, 0x75, 0x73, 0x65,
+	0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70,
+	0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72,
+	0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x3c, 0x0a, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x44, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x64, 0x65, 0x72, 0x69, 0x76, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x3a, 0x3b, 0xea, 0x41, 0x38, 0x0a, 0x13, 0x61, 0x70, 0x69, 0x2e, 0x63,
+	0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75, 0x65, 0x12, 0x21,
+	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x7d, 0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x2f, 0x7b, 0x69, 0x73, 0x73, 0x75, 0x65,
+	0x7d, 0x22, 0x2a, 0x0a, 0x10, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x4c, 0x69, 0x73, 0x74, 0x43,
+	0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x22, 0x61, 0x0a,
+	0x08, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x12, 0x2e, 0x0a, 0x05, 0x69, 0x73, 0x73,
+	0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x18, 0xfa, 0x41, 0x15, 0x0a, 0x13, 0x61,
+	0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73,
+	0x75, 0x65, 0x52, 0x05, 0x69, 0x73, 0x73, 0x75, 0x65, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x78, 0x74,
+	0x5f, 0x69, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0d, 0x65, 0x78, 0x74, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72,
+	0x22, 0xbd, 0x05, 0x0a, 0x0d, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x44, 0x0a, 0x0c, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76,
+	0x61, 0x6c, 0x5f, 0x64, 0x65, 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x21, 0xfa, 0x41,
+	0x1b, 0x0a, 0x19, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x66, 0xe0, 0x41, 0x03, 0x52,
+	0x0b, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x66, 0x12, 0x35, 0x0a, 0x09,
+	0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x42,
+	0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x09, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76,
+	0x65, 0x72, 0x73, 0x12, 0x41, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20,
+	0x01, 0x28, 0x0e, 0x32, 0x29, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x2e,
+	0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06,
+	0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3a, 0x0a, 0x08, 0x73, 0x65, 0x74, 0x5f, 0x74, 0x69,
+	0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+	0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
+	0x74, 0x61, 0x6d, 0x70, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x07, 0x73, 0x65, 0x74, 0x54, 0x69,
+	0x6d, 0x65, 0x12, 0x32, 0x0a, 0x06, 0x73, 0x65, 0x74, 0x74, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01,
+	0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62,
+	0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x03, 0x52, 0x06,
+	0x73, 0x65, 0x74, 0x74, 0x65, 0x72, 0x12, 0x19, 0x0a, 0x05, 0x70, 0x68, 0x61, 0x73, 0x65, 0x18,
+	0x07, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x05, 0x70, 0x68, 0x61, 0x73,
+	0x65, 0x12, 0x3a, 0x0a, 0x0c, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65,
+	0x52, 0x0b, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0xb1, 0x01,
+	0x0a, 0x0e, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73,
+	0x12, 0x1f, 0x0a, 0x1b, 0x41, 0x50, 0x50, 0x52, 0x4f, 0x56, 0x41, 0x4c, 0x5f, 0x53, 0x54, 0x41,
+	0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10,
+	0x00, 0x12, 0x0b, 0x0a, 0x07, 0x4e, 0x4f, 0x54, 0x5f, 0x53, 0x45, 0x54, 0x10, 0x01, 0x12, 0x10,
+	0x0a, 0x0c, 0x4e, 0x45, 0x45, 0x44, 0x53, 0x5f, 0x52, 0x45, 0x56, 0x49, 0x45, 0x57, 0x10, 0x02,
+	0x12, 0x06, 0x0a, 0x02, 0x4e, 0x41, 0x10, 0x03, 0x12, 0x14, 0x0a, 0x10, 0x52, 0x45, 0x56, 0x49,
+	0x45, 0x57, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x45, 0x44, 0x10, 0x04, 0x12, 0x12,
+	0x0a, 0x0e, 0x52, 0x45, 0x56, 0x49, 0x45, 0x57, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x54, 0x45, 0x44,
+	0x10, 0x05, 0x12, 0x0d, 0x0a, 0x09, 0x4e, 0x45, 0x45, 0x44, 0x5f, 0x49, 0x4e, 0x46, 0x4f, 0x10,
+	0x06, 0x12, 0x0c, 0x0a, 0x08, 0x41, 0x50, 0x50, 0x52, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x07, 0x12,
+	0x10, 0x0a, 0x0c, 0x4e, 0x4f, 0x54, 0x5f, 0x41, 0x50, 0x50, 0x52, 0x4f, 0x56, 0x45, 0x44, 0x10,
+	0x08, 0x3a, 0x5d, 0xea, 0x41, 0x5a, 0x0a, 0x1b, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61,
+	0x6c, 0x75, 0x65, 0x12, 0x3b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x2f, 0x7b,
+	0x69, 0x73, 0x73, 0x75, 0x65, 0x7d, 0x2f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56,
+	0x61, 0x6c, 0x75, 0x65, 0x73, 0x2f, 0x7b, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x7d,
+	0x2a, 0x40, 0x0a, 0x0a, 0x44, 0x65, 0x72, 0x69, 0x76, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a,
+	0x0a, 0x16, 0x44, 0x45, 0x52, 0x49, 0x56, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53,
+	0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x45, 0x58,
+	0x50, 0x4c, 0x49, 0x43, 0x49, 0x54, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x52, 0x55, 0x4c, 0x45,
+	0x10, 0x02, 0x2a, 0x4d, 0x0a, 0x11, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65,
+	0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x15, 0x0a, 0x11, 0x53, 0x54, 0x41, 0x54, 0x45,
+	0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a,
+	0x0a, 0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45,
+	0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x50, 0x41, 0x4d, 0x10,
+	0x03, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d,
+	0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73,
+	0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73,
+	0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_goTypes = []interface{}{
+	(Derivation)(0),                   // 0: monorail.v3.Derivation
+	(IssueContentState)(0),            // 1: monorail.v3.IssueContentState
+	(Comment_Type)(0),                 // 2: monorail.v3.Comment.Type
+	(ApprovalValue_ApprovalStatus)(0), // 3: monorail.v3.ApprovalValue.ApprovalStatus
+	(*Comment)(nil),                   // 4: monorail.v3.Comment
+	(*FieldValue)(nil),                // 5: monorail.v3.FieldValue
+	(*Issue)(nil),                     // 6: monorail.v3.Issue
+	(*IssuesListColumn)(nil),          // 7: monorail.v3.IssuesListColumn
+	(*IssueRef)(nil),                  // 8: monorail.v3.IssueRef
+	(*ApprovalValue)(nil),             // 9: monorail.v3.ApprovalValue
+	(*Comment_Attachment)(nil),        // 10: monorail.v3.Comment.Attachment
+	(*Comment_Amendment)(nil),         // 11: monorail.v3.Comment.Amendment
+	(*Issue_ComponentValue)(nil),      // 12: monorail.v3.Issue.ComponentValue
+	(*Issue_LabelValue)(nil),          // 13: monorail.v3.Issue.LabelValue
+	(*Issue_StatusValue)(nil),         // 14: monorail.v3.Issue.StatusValue
+	(*Issue_UserValue)(nil),           // 15: monorail.v3.Issue.UserValue
+	(*timestamppb.Timestamp)(nil),     // 16: google.protobuf.Timestamp
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_depIdxs = []int32{
+	1,  // 0: monorail.v3.Comment.state:type_name -> monorail.v3.IssueContentState
+	2,  // 1: monorail.v3.Comment.type:type_name -> monorail.v3.Comment.Type
+	16, // 2: monorail.v3.Comment.create_time:type_name -> google.protobuf.Timestamp
+	11, // 3: monorail.v3.Comment.amendments:type_name -> monorail.v3.Comment.Amendment
+	10, // 4: monorail.v3.Comment.attachments:type_name -> monorail.v3.Comment.Attachment
+	0,  // 5: monorail.v3.FieldValue.derivation:type_name -> monorail.v3.Derivation
+	1,  // 6: monorail.v3.Issue.state:type_name -> monorail.v3.IssueContentState
+	14, // 7: monorail.v3.Issue.status:type_name -> monorail.v3.Issue.StatusValue
+	15, // 8: monorail.v3.Issue.owner:type_name -> monorail.v3.Issue.UserValue
+	15, // 9: monorail.v3.Issue.cc_users:type_name -> monorail.v3.Issue.UserValue
+	13, // 10: monorail.v3.Issue.labels:type_name -> monorail.v3.Issue.LabelValue
+	12, // 11: monorail.v3.Issue.components:type_name -> monorail.v3.Issue.ComponentValue
+	5,  // 12: monorail.v3.Issue.field_values:type_name -> monorail.v3.FieldValue
+	8,  // 13: monorail.v3.Issue.merged_into_issue_ref:type_name -> monorail.v3.IssueRef
+	8,  // 14: monorail.v3.Issue.blocked_on_issue_refs:type_name -> monorail.v3.IssueRef
+	8,  // 15: monorail.v3.Issue.blocking_issue_refs:type_name -> monorail.v3.IssueRef
+	16, // 16: monorail.v3.Issue.create_time:type_name -> google.protobuf.Timestamp
+	16, // 17: monorail.v3.Issue.close_time:type_name -> google.protobuf.Timestamp
+	16, // 18: monorail.v3.Issue.modify_time:type_name -> google.protobuf.Timestamp
+	16, // 19: monorail.v3.Issue.component_modify_time:type_name -> google.protobuf.Timestamp
+	16, // 20: monorail.v3.Issue.status_modify_time:type_name -> google.protobuf.Timestamp
+	16, // 21: monorail.v3.Issue.owner_modify_time:type_name -> google.protobuf.Timestamp
+	3,  // 22: monorail.v3.ApprovalValue.status:type_name -> monorail.v3.ApprovalValue.ApprovalStatus
+	16, // 23: monorail.v3.ApprovalValue.set_time:type_name -> google.protobuf.Timestamp
+	5,  // 24: monorail.v3.ApprovalValue.field_values:type_name -> monorail.v3.FieldValue
+	1,  // 25: monorail.v3.Comment.Attachment.state:type_name -> monorail.v3.IssueContentState
+	0,  // 26: monorail.v3.Issue.ComponentValue.derivation:type_name -> monorail.v3.Derivation
+	0,  // 27: monorail.v3.Issue.LabelValue.derivation:type_name -> monorail.v3.Derivation
+	0,  // 28: monorail.v3.Issue.StatusValue.derivation:type_name -> monorail.v3.Derivation
+	0,  // 29: monorail.v3.Issue.UserValue.derivation:type_name -> monorail.v3.Derivation
+	30, // [30:30] is the sub-list for method output_type
+	30, // [30:30] is the sub-list for method input_type
+	30, // [30:30] is the sub-list for extension type_name
+	30, // [30:30] is the sub-list for extension extendee
+	0,  // [0:30] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Comment); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Issue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IssuesListColumn); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IssueRef); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ApprovalValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Comment_Attachment); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Comment_Amendment); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Issue_ComponentValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Issue_LabelValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Issue_StatusValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Issue_UserValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDesc,
+			NumEnums:      4,
+			NumMessages:   12,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_depIdxs,
+		EnumInfos:         file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_enumTypes,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_depIdxs = nil
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/issue_objects.proto b/analysis/internal/bugs/monorail/api_proto/issue_objects.proto
new file mode 100644
index 0000000..6112ba4
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/issue_objects.proto
@@ -0,0 +1,349 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for issues and related business
+// objects, e.g., field values, comments, and attachments.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "google/protobuf/timestamp.proto";
+
+// Represents a comment and any associated changes to an Issue.
+//
+// Comments cannot be Created or Updated through standard methods. The
+// OUTPUT_ONLY annotations here indicate fields that would never be provided
+// by the user even if these methods were made available.
+// Next available tag: 11.
+message Comment {
+
+  // The type of comment.
+  // Next available tag: 9
+  enum Type {
+    // The default comment type. Used if type is omitted.
+    UNSPECIFIED = 0;
+    // A standard comment on an issue.
+    COMMENT = 1;
+    // A comment representing a new description for the issue.
+    DESCRIPTION = 2;
+  }
+
+  // A file attached to a comment.
+  // Next available tag: 8
+  message Attachment {
+    // The name of the attached file.
+    string filename = 1;
+    // It is possible for attachments to be deleted (and undeleted) by the
+    // uploader. The name of deleted attachments are still shown, but the
+    // content is not available.
+    IssueContentState state = 2;
+    // Size of the attached file in bytes.
+    uint64 size = 3;
+    // The type of content contained in the file, using the IANA's media type
+    // https://www.iana.org/assignments/media-types/media-types.xhtml.
+    string media_type = 4;
+    // The URI used for a preview of the attachment (when relelvant).
+    string thumbnail_uri = 5;
+    // The URI used to view the content of the attachment.
+    string view_uri = 6;
+    // The URI used to download the content of the attachment.
+    string download_uri = 7;
+  }
+
+  // This message is only suitable for displaying the amendment to users.
+  // We don't currently offer structured amendments that client code can
+  // reason about, field names can be ambiguous, and we don't have
+  // old_value for most changes.
+  // Next available tag: 4
+  message Amendment {
+    // This may be the name of a built-in or custom field, or relative to
+    // an approval field name.
+    string field_name = 1;
+    // This may be a new value that overwrote the old value, e.g., "Assigned",
+    // or it may be a space-separated list of changes, e.g., "Size-L -Size-S".
+    string new_or_delta_value = 2;
+    // old_value is only used when the user changes the summary.
+    string old_value = 3;
+  }
+
+  option (google.api.resource) = {
+    type: "api.crbug.com/Comment"
+    pattern: "projects/{project}/issues/{issue}/comments/{comment}"
+  };
+
+  // Resource name of the comment.
+  string name = 1;
+  // The state of the comment.
+  IssueContentState state = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The type of comment.
+  Type type = 3;
+  // The text of the comment.
+  string content = 4;
+  // Resource name of the author of the comment.
+  string commenter = 5 [
+    (google.api.resource_reference) = { type: "api.crbug.com/User" },
+    (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+  // The time this comment was added to the Issue.
+  google.protobuf.Timestamp create_time = 6
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // Optional string full text of an email that caused this comment to be added.
+  string inbound_message = 7 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The approval this comment is associated with, if applicable.
+  string approval = 8
+      [(google.api.resource_reference) = { type: "api.crbug.com/ApprovalValue" }];
+  // Any changes made to the issue in association with this comment.
+  repeated Amendment amendments = 9 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // Any attachments uploaded in association with this comment.
+  repeated Attachment attachments = 10
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+}
+
+
+// Many values on an issue can be set either explicitly or by a rule.
+//
+// Note: Though Derivations are used as OUTPUT_ONLY, values including them
+// will still be ingested even though the Derivation is ignored.
+//
+// Next available tag: 3
+enum Derivation {
+  // The default derivation. This value is used if the derivation is omitted.
+  DERIVATION_UNSPECIFIED = 0;
+  // The value was explicitly set on the issue.
+  EXPLICIT = 1;
+  // Value was auto-applied to the issue based on a project's rule. See
+  // monorail/doc/userguide/project-owners.md#how-to-configure-filter-rules
+  RULE = 2;
+}
+
+
+// A value of a custom field for an issue.
+// Next available tag: 5
+message FieldValue {
+  // The project-defined field associated with this value
+  string field = 1 [
+      (google.api.resource_reference) = { type: "api.crbug.com/FieldDef" }];
+  // The value associated with the field.
+  // Mapping of field types to string value:
+  // ENUM_TYPE(int) => str(value)
+  // INT_TYPE(int) => str(value)
+  // STR_TYPE(str) => value
+  // USER_TYPE(int) => the user's resource name
+  // DATE_TYPE(int) => str(int) representing time in seconds since epoch
+  // URL_TYPE(str) => value
+  string value = 2;
+  // How the value was derived.
+  Derivation derivation = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // Issues with phase-specific fields can have values for each phase.
+  string phase = 4;
+}
+
+// Documents and tracks a bug, task, or feature request within a Project.
+// Next available tag: 23
+message Issue {
+  option (google.api.resource) = {
+    type: "api.crbug.com/Issue"
+    pattern: "projects/{project}/issues/{issue}"
+  };
+
+  // A possibly rule-derived component for the issue.
+  // Next available tag: 3
+  message ComponentValue {
+    // AIP resource name of the component.
+    string component = 1 [
+      (google.api.resource_reference) = { type: "api.crbug.com/ComponentDef" }
+    ];
+    // How the component was derived.
+    Derivation derivation = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+  }
+
+  // A possibly rule-derived label for an issue.
+  // Next available tag: 3
+  message LabelValue {
+    // The label.
+    string label = 1;
+    // How the label was derived.
+    Derivation derivation = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+  }
+
+  // A possibly rule-derived status for an issue.
+  // Next available tag: 3
+  message StatusValue {
+    // The status of the issue. Note that in rare cases this can be a
+    // value not defined in the project's StatusDefs (e.g. if the issue
+    // was moved from another project).
+    string status = 1;
+    // How the status was derived.
+    Derivation derivation = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+  }
+
+  // A possibly rule-derived user value on an issue.
+  // Next available tag: 3
+  message UserValue {
+    // The user.
+    string user = 1
+        [(google.api.resource_reference) = { type: "api.crbug.com/User" }];
+    // How the user value was derived.
+    Derivation derivation = 2 [(google.api.field_behavior) = OUTPUT_ONLY];
+  }
+
+  // Resource name of the issue.
+  string name = 1;
+  // A brief summary of the issue. Generally displayed as a user-facing title.
+  // TODO(monorail:6988): The UI limits summary length while the backend does
+  // not. Resolve this discrepancy.
+  string summary = 2;
+  // The state of the issue.
+  IssueContentState state = 3 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The current status of the issue.
+  StatusValue status = 4 [(google.api.field_behavior) = REQUIRED];
+  // The user who created the issue.
+  string reporter = 5 [
+    (google.api.resource_reference) = { type: "api.crbug.com/User" },
+    (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+  // The user currently responsible for the issue. This user must be a member of
+  // the Project.
+  UserValue owner = 6;
+  // Additional users receiving notifications on the issue.
+  repeated UserValue cc_users = 7;
+  // Labels applied to the issue.
+  repeated LabelValue labels = 8;
+  // Components the issue is associated with.
+  repeated ComponentValue components = 9;
+  // Values for custom fields on the issue.
+  repeated FieldValue field_values = 10;
+  // An issue can be merged into another. If this value is set, the issue
+  // referred to should be considered the primary source for further updates.
+  IssueRef merged_into_issue_ref = 11;
+  // Issues preventing the completion of this issue.
+  repeated IssueRef blocked_on_issue_refs = 12;
+  // Issues for which this issue is blocking completion.
+  repeated IssueRef blocking_issue_refs = 13;
+  // The time the issue was reported.
+  google.protobuf.Timestamp create_time = 14
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The most recent time the issue was closed.
+  google.protobuf.Timestamp close_time = 15
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The most recent time the issue was modified.
+  google.protobuf.Timestamp modify_time = 16
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The most recent time a component value was modified.
+  google.protobuf.Timestamp component_modify_time = 17
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The most recent time the status value was modified.
+  google.protobuf.Timestamp status_modify_time = 18
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The most recent time the owner made a modification to the issue.
+  google.protobuf.Timestamp owner_modify_time = 19
+      [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The number of attachments associated with the issue.
+  uint32 attachment_count = 20 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // The number of users who have starred the issue.
+  uint32 star_count = 21 [(google.api.field_behavior) = OUTPUT_ONLY];
+  // Phases of a process the issue is tracking (if applicable).
+  // See monorail/doc/userguide/concepts.md#issue-approvals-and-gates
+  repeated string phases = 22 [
+      (google.api.field_behavior) = OUTPUT_ONLY];
+}
+
+// States that an issue or its comments can be in (aip.dev/216).
+// Next available tag: 4
+enum IssueContentState {
+  // The default value. This value is used if the state is omitted.
+  STATE_UNSPECIFIED = 0;
+  // The Issue or Comment is available.
+  ACTIVE = 1;
+  // The Issue or Comment has been deleted.
+  DELETED = 2;
+  // The Issue or Comment has been flagged as spam.
+  // Takes precedent over DELETED.
+  SPAM = 3;
+}
+
+// Specifies a column in an issues list view.
+// Next available tag: 2
+message IssuesListColumn {
+  // Column name shown in the column header.
+  string column = 1;
+}
+
+// Refers to an issue that may or may not be tracked in Monorail.
+// At least one of `issue` and `ext_identifier` MUST be set; they MUST NOT both
+// be set.
+// Next available tag: 3
+message IssueRef {
+  // Resource name of an issue tracked in Monorail
+  string issue = 1
+      [(google.api.resource_reference) = { type: "api.crbug.com/Issue" }];
+  // For referencing external issues, e.g. b/1234, or a dangling reference
+  // to an old 'codesite' issue.
+  // TODO(monorail:7208): add more documentation on dangling references.
+  string ext_identifier = 2;
+}
+
+// Documents and tracks an approval process.
+// See monorail/doc/userguide/concepts.md#issue-approvals-and-gates
+// Next available tag: 9
+message ApprovalValue {
+  option (google.api.resource) = {
+     type: "api.crbug.com/ApprovalValue"
+     pattern: "projects/{project}/issues/{issue}/approvalValues/{approval}"
+   };
+
+  // Potential states for an approval. Note that these statuses cause different
+  // sets of notifications. See monorail/doc/userguide/email.md
+  // Next available tag: 9
+  enum ApprovalStatus {
+    // The default approval status. This value is used if the status is omitted.
+    APPROVAL_STATUS_UNSPECIFIED = 0;
+    // No status has yet been set on this value.
+    NOT_SET = 1;
+    // This issue needs review from the approvers for this phase.
+    NEEDS_REVIEW = 2;
+    // This approval is not needed for this issue for this phase.
+    NA = 3;
+    // The issue is ready for the approvers to review.
+    REVIEW_REQUESTED = 4;
+    // The approvers have started reviewing this issue.
+    REVIEW_STARTED = 5;
+    // The approvers need more information.
+    NEED_INFO = 6;
+    // The approvers have approved this issue for this phase.
+    APPROVED = 7;
+    // The approvers have indicated this issue is not approved for this phase.
+    NOT_APPROVED = 8;
+  }
+
+  // The resource name.
+  string name = 1;
+  // The resource name of the ApprovalDef.
+  string approval_def = 2 [
+      (google.api.resource_reference) = { type: "api.crbug.com/ApprovalDef" },
+      (google.api.field_behavior) = OUTPUT_ONLY];
+  // The users able to grant this approval.
+  repeated string approvers = 3 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }];
+  // The current status of the approval.
+  ApprovalStatus status = 4;
+  // The time `status` was last set.
+  google.protobuf.Timestamp set_time = 5 [
+      (google.api.field_behavior) = OUTPUT_ONLY];
+  // The user who most recently set `status`.
+  string setter = 6 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" },
+      (google.api.field_behavior) = OUTPUT_ONLY];
+  // The phase the approval is associated with (if applicable).
+  string phase = 7 [
+      (google.api.field_behavior) = OUTPUT_ONLY];
+  // FieldValues with `approval_def` as their parent.
+  repeated FieldValue field_values = 8;
+}
\ No newline at end of file
diff --git a/analysis/internal/bugs/monorail/api_proto/issues.pb.go b/analysis/internal/bugs/monorail/api_proto/issues.pb.go
new file mode 100644
index 0000000..c69bf62
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/issues.pb.go
@@ -0,0 +1,2813 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/issues.proto
+
+package api_proto
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// The type of notification a change should trigger.
+// See monorail/doc/userguide/email.md
+// Next available tag: 2
+type NotifyType int32
+
+const (
+	// The default value. This value is unused.
+	NotifyType_NOTIFY_TYPE_UNSPECIFIED NotifyType = 0
+	// An email notification should be sent.
+	NotifyType_EMAIL NotifyType = 1
+	// No notifcation should be triggered at all.
+	NotifyType_NO_NOTIFICATION NotifyType = 2
+)
+
+// Enum value maps for NotifyType.
+var (
+	NotifyType_name = map[int32]string{
+		0: "NOTIFY_TYPE_UNSPECIFIED",
+		1: "EMAIL",
+		2: "NO_NOTIFICATION",
+	}
+	NotifyType_value = map[string]int32{
+		"NOTIFY_TYPE_UNSPECIFIED": 0,
+		"EMAIL":                   1,
+		"NO_NOTIFICATION":         2,
+	}
+)
+
+func (x NotifyType) Enum() *NotifyType {
+	p := new(NotifyType)
+	*p = x
+	return p
+}
+
+func (x NotifyType) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (NotifyType) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_enumTypes[0].Descriptor()
+}
+
+func (NotifyType) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_enumTypes[0]
+}
+
+func (x NotifyType) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use NotifyType.Descriptor instead.
+func (NotifyType) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{0}
+}
+
+// The request message for Issues.GetIssue.
+// Next available tag: 2
+type GetIssueRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the issue to request.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetIssueRequest) Reset() {
+	*x = GetIssueRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetIssueRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetIssueRequest) ProtoMessage() {}
+
+func (x *GetIssueRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetIssueRequest.ProtoReflect.Descriptor instead.
+func (*GetIssueRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GetIssueRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// The request message for Issues.BatchGetIssues.
+// Next available tag: 3
+type BatchGetIssuesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The project name from which to batch get issues. If included, the parent
+	// of all the issues specified in `names` must match this field.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The issues to request. Maximum of 100 can be retrieved.
+	Names []string `protobuf:"bytes,2,rep,name=names,proto3" json:"names,omitempty"`
+}
+
+func (x *BatchGetIssuesRequest) Reset() {
+	*x = BatchGetIssuesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetIssuesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetIssuesRequest) ProtoMessage() {}
+
+func (x *BatchGetIssuesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetIssuesRequest.ProtoReflect.Descriptor instead.
+func (*BatchGetIssuesRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *BatchGetIssuesRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *BatchGetIssuesRequest) GetNames() []string {
+	if x != nil {
+		return x.Names
+	}
+	return nil
+}
+
+// The response message for Issues.BatchGetIssues.
+// Next available tag: 2
+type BatchGetIssuesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Issues matching the given request.
+	Issues []*Issue `protobuf:"bytes,1,rep,name=issues,proto3" json:"issues,omitempty"`
+}
+
+func (x *BatchGetIssuesResponse) Reset() {
+	*x = BatchGetIssuesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetIssuesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetIssuesResponse) ProtoMessage() {}
+
+func (x *BatchGetIssuesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetIssuesResponse.ProtoReflect.Descriptor instead.
+func (*BatchGetIssuesResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *BatchGetIssuesResponse) GetIssues() []*Issue {
+	if x != nil {
+		return x.Issues
+	}
+	return nil
+}
+
+// The request message for Issues.SearchIssues.
+// Next available tag: 6
+type SearchIssuesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The names of Projects in which to search issues.
+	Projects []string `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"`
+	// The query string can contain any number of free text and
+	// field search expressions.
+	// Please see https://bugs.chromium.org/p/chromium/issues/searchtips for more
+	// details of how the query string works.
+	//
+	// Canned queries have been deprecated in v3 in favor of search scoping using
+	// parentheses support.
+	// For clients who previously used canned queries, we're providing the
+	// mapping of legacy canned query IDs to Monorail search syntax:
+	//   - Format: (can_id, description, query_string)
+	//   - (1, 'All issues', '')
+	//   - (2, 'Open issues', 'is:open')
+	//   - (3, 'Open and owned by me', 'is:open owner:me')
+	//   - (4, 'Open and reported by me', 'is:open reporter:me')
+	//   - (5, 'Open and starred by me', 'is:open is:starred')
+	//   - (6, 'New issues', 'status:new')
+	//   - (7, 'Issues to verify', 'status=fixed,done')
+	//   - (8, 'Open with comment by me', 'is:open commentby:me')
+	Query string `protobuf:"bytes,2,opt,name=query,proto3" json:"query,omitempty"`
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	// If unspecified, at most 100 items will be returned.
+	// The maximum value is 100; values above 100 will be coerced to 100.
+	PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `SearchIssues` call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to `SearchIssues` must match
+	// the call that provided the page token.
+	PageToken string `protobuf:"bytes,4,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+	// The string of comma separated field names used to order the items.
+	// Adding '-' before a field, reverses the sort order.
+	// E.g. 'stars,-status' sorts the items by number of stars, high to low,
+	// then by status, low to high.
+	OrderBy string `protobuf:"bytes,5,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"`
+}
+
+func (x *SearchIssuesRequest) Reset() {
+	*x = SearchIssuesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SearchIssuesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SearchIssuesRequest) ProtoMessage() {}
+
+func (x *SearchIssuesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SearchIssuesRequest.ProtoReflect.Descriptor instead.
+func (*SearchIssuesRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *SearchIssuesRequest) GetProjects() []string {
+	if x != nil {
+		return x.Projects
+	}
+	return nil
+}
+
+func (x *SearchIssuesRequest) GetQuery() string {
+	if x != nil {
+		return x.Query
+	}
+	return ""
+}
+
+func (x *SearchIssuesRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *SearchIssuesRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+func (x *SearchIssuesRequest) GetOrderBy() string {
+	if x != nil {
+		return x.OrderBy
+	}
+	return ""
+}
+
+// The response message for Issues.SearchIssues.
+// Next available tag: 3
+type SearchIssuesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Issues matching the given request.
+	Issues []*Issue `protobuf:"bytes,1,rep,name=issues,proto3" json:"issues,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *SearchIssuesResponse) Reset() {
+	*x = SearchIssuesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *SearchIssuesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SearchIssuesResponse) ProtoMessage() {}
+
+func (x *SearchIssuesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use SearchIssuesResponse.ProtoReflect.Descriptor instead.
+func (*SearchIssuesResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *SearchIssuesResponse) GetIssues() []*Issue {
+	if x != nil {
+		return x.Issues
+	}
+	return nil
+}
+
+func (x *SearchIssuesResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// The request message for Issues.ListComments.
+// Next available tag: 5
+type ListCommentsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the issue for which to list comments.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	// If unspecified, at most 100 items will be returned.
+	// The maximum value is 100; values above 100 will be coerced to 100.
+	PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `ListComments` call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to `ListComments` must
+	// match the call that provided the page token.
+	PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+	// For our initial release this filter only supports filtering to comments
+	// related to a specific approval.
+	// For example `approval = "projects/monorail/approvalDefs/1"`,
+	// Note that no further logical or comparison operators are supported
+	Filter string `protobuf:"bytes,4,opt,name=filter,proto3" json:"filter,omitempty"`
+}
+
+func (x *ListCommentsRequest) Reset() {
+	*x = ListCommentsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListCommentsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListCommentsRequest) ProtoMessage() {}
+
+func (x *ListCommentsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListCommentsRequest.ProtoReflect.Descriptor instead.
+func (*ListCommentsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ListCommentsRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *ListCommentsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *ListCommentsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+func (x *ListCommentsRequest) GetFilter() string {
+	if x != nil {
+		return x.Filter
+	}
+	return ""
+}
+
+// The response message for Issues.ListComments
+// Next available tag: 3
+type ListCommentsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The comments from the specified issue.
+	Comments []*Comment `protobuf:"bytes,1,rep,name=comments,proto3" json:"comments,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *ListCommentsResponse) Reset() {
+	*x = ListCommentsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListCommentsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListCommentsResponse) ProtoMessage() {}
+
+func (x *ListCommentsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListCommentsResponse.ProtoReflect.Descriptor instead.
+func (*ListCommentsResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ListCommentsResponse) GetComments() []*Comment {
+	if x != nil {
+		return x.Comments
+	}
+	return nil
+}
+
+func (x *ListCommentsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// An attachment to upload to a comment or description.
+// Next available tag: 3
+type AttachmentUpload struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Filename string `protobuf:"bytes,1,opt,name=filename,proto3" json:"filename,omitempty"`
+	Content  []byte `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"`
+}
+
+func (x *AttachmentUpload) Reset() {
+	*x = AttachmentUpload{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *AttachmentUpload) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AttachmentUpload) ProtoMessage() {}
+
+func (x *AttachmentUpload) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use AttachmentUpload.ProtoReflect.Descriptor instead.
+func (*AttachmentUpload) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *AttachmentUpload) GetFilename() string {
+	if x != nil {
+		return x.Filename
+	}
+	return ""
+}
+
+func (x *AttachmentUpload) GetContent() []byte {
+	if x != nil {
+		return x.Content
+	}
+	return nil
+}
+
+// Holds changes to one issue, used in ModifyIssuesRequest.
+// Next available tag: 9
+type IssueDelta struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The issue's `name` field is used to identify the issue to be
+	// updated. `issue.name` must always be filled.
+	//
+	// Values with rule-based Derivation within `issue` and in `field_vals_remove`
+	// will be ignored.
+	Issue *Issue `protobuf:"bytes,1,opt,name=issue,proto3" json:"issue,omitempty"`
+	// The list of fields in `issue` to be updated.
+	//
+	// Repeated fields set on `issue` will be appended to.
+	//
+	// Non-repeated fields (e.g. `owner`) can be set with `issue.owner` set and
+	// either 'owner' or 'owner.user' added to `update_mask`.
+	// To unset non-repeated fields back to their default value, `issue.owner`
+	// must contain the default value and `update_mask` must include 'owner.user'
+	// NOT 'owner'.
+	//
+	// Its `field_values`, however, are a special case. Fields can be specified as
+	// single-value or multi-value in their FieldDef.
+	//
+	// Single-value Field: if there is preexisting FieldValue with the same
+	// `field` and `phase`, it will be REPLACED.
+	//
+	// Multi-value Field: a new value will be appended, unless the same `field`,
+	// `phase`, `value` combination already exists. In that case, the FieldValue
+	// will be ignored. In other words, duplicate values are ignored.
+	// (With the exception of crbug.com/monorail/8137 until it is fixed).
+	UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"`
+	// Cc's to remove.
+	CcsRemove []string `protobuf:"bytes,3,rep,name=ccs_remove,json=ccsRemove,proto3" json:"ccs_remove,omitempty"`
+	// Blocked_on issues to remove.
+	BlockedOnIssuesRemove []*IssueRef `protobuf:"bytes,4,rep,name=blocked_on_issues_remove,json=blockedOnIssuesRemove,proto3" json:"blocked_on_issues_remove,omitempty"`
+	// Blocking issues to remove.
+	BlockingIssuesRemove []*IssueRef `protobuf:"bytes,5,rep,name=blocking_issues_remove,json=blockingIssuesRemove,proto3" json:"blocking_issues_remove,omitempty"`
+	// Components to remove.
+	ComponentsRemove []string `protobuf:"bytes,6,rep,name=components_remove,json=componentsRemove,proto3" json:"components_remove,omitempty"`
+	// Labels to remove.
+	LabelsRemove []string `protobuf:"bytes,7,rep,name=labels_remove,json=labelsRemove,proto3" json:"labels_remove,omitempty"`
+	// FieldValues to remove. Any values that did not already exist will be
+	// ignored e.g. if you append a FieldValue in issue and remove it here, it
+	// will still be added.
+	FieldValsRemove []*FieldValue `protobuf:"bytes,8,rep,name=field_vals_remove,json=fieldValsRemove,proto3" json:"field_vals_remove,omitempty"`
+}
+
+func (x *IssueDelta) Reset() {
+	*x = IssueDelta{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IssueDelta) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IssueDelta) ProtoMessage() {}
+
+func (x *IssueDelta) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IssueDelta.ProtoReflect.Descriptor instead.
+func (*IssueDelta) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *IssueDelta) GetIssue() *Issue {
+	if x != nil {
+		return x.Issue
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetUpdateMask() *fieldmaskpb.FieldMask {
+	if x != nil {
+		return x.UpdateMask
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetCcsRemove() []string {
+	if x != nil {
+		return x.CcsRemove
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetBlockedOnIssuesRemove() []*IssueRef {
+	if x != nil {
+		return x.BlockedOnIssuesRemove
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetBlockingIssuesRemove() []*IssueRef {
+	if x != nil {
+		return x.BlockingIssuesRemove
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetComponentsRemove() []string {
+	if x != nil {
+		return x.ComponentsRemove
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetLabelsRemove() []string {
+	if x != nil {
+		return x.LabelsRemove
+	}
+	return nil
+}
+
+func (x *IssueDelta) GetFieldValsRemove() []*FieldValue {
+	if x != nil {
+		return x.FieldValsRemove
+	}
+	return nil
+}
+
+// Changes to make to an ApprovalValue. Used to ModifyIssueApprovalValues or
+// to MakeIssueFromTemplate.
+//
+// NOTE: The same handling of FieldValues discussed in IssueDelta applies here.
+// Next available tag: 6
+type ApprovalDelta struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The ApprovalValue we want to update. `approval_value.name` must always be
+	// set.
+	ApprovalValue *ApprovalValue `protobuf:"bytes,1,opt,name=approval_value,json=approvalValue,proto3" json:"approval_value,omitempty"`
+	// Repeated fields found in `update_mask` will be appended to.
+	UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"`
+	// Resource names of the approvers we want to remove.
+	ApproversRemove []string `protobuf:"bytes,3,rep,name=approvers_remove,json=approversRemove,proto3" json:"approvers_remove,omitempty"`
+	// FieldValues that do not belong to `approval_value` will trigger error.
+	FieldValsRemove []*FieldValue `protobuf:"bytes,5,rep,name=field_vals_remove,json=fieldValsRemove,proto3" json:"field_vals_remove,omitempty"` // TODO(crbug.com/monorail/8019): add Attachment uploading and removing.
+}
+
+func (x *ApprovalDelta) Reset() {
+	*x = ApprovalDelta{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ApprovalDelta) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ApprovalDelta) ProtoMessage() {}
+
+func (x *ApprovalDelta) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ApprovalDelta.ProtoReflect.Descriptor instead.
+func (*ApprovalDelta) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *ApprovalDelta) GetApprovalValue() *ApprovalValue {
+	if x != nil {
+		return x.ApprovalValue
+	}
+	return nil
+}
+
+func (x *ApprovalDelta) GetUpdateMask() *fieldmaskpb.FieldMask {
+	if x != nil {
+		return x.UpdateMask
+	}
+	return nil
+}
+
+func (x *ApprovalDelta) GetApproversRemove() []string {
+	if x != nil {
+		return x.ApproversRemove
+	}
+	return nil
+}
+
+func (x *ApprovalDelta) GetFieldValsRemove() []*FieldValue {
+	if x != nil {
+		return x.FieldValsRemove
+	}
+	return nil
+}
+
+// The request message for Issues.ModifyIssues.
+// Next available tag: 5
+type ModifyIssuesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The issue changes to make. A maximum of 100 issue changes can be requested.
+	// There is also a constraint of 50 additional 'impacted issues' per
+	// ModifyIssuesRequest. 'Impacted issues' are issues that are adding/removing
+	// `blocked_on`, `blocking`, or `merge`
+	// If you encounter this error, consider significantly smaller batches.
+	Deltas []*IssueDelta `protobuf:"bytes,1,rep,name=deltas,proto3" json:"deltas,omitempty"`
+	// The type of notification the modifications should trigger.
+	NotifyType NotifyType `protobuf:"varint,2,opt,name=notify_type,json=notifyType,proto3,enum=monorail.v3.NotifyType" json:"notify_type,omitempty"`
+	// The comment text that should be added to each issue in delta.
+	// Max length is 51200 characters.
+	CommentContent string `protobuf:"bytes,3,opt,name=comment_content,json=commentContent,proto3" json:"comment_content,omitempty"`
+	// The attachment that will be to each comment for each issue in delta.
+	Uploads []*AttachmentUpload `protobuf:"bytes,4,rep,name=uploads,proto3" json:"uploads,omitempty"`
+}
+
+func (x *ModifyIssuesRequest) Reset() {
+	*x = ModifyIssuesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[10]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ModifyIssuesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ModifyIssuesRequest) ProtoMessage() {}
+
+func (x *ModifyIssuesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[10]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ModifyIssuesRequest.ProtoReflect.Descriptor instead.
+func (*ModifyIssuesRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *ModifyIssuesRequest) GetDeltas() []*IssueDelta {
+	if x != nil {
+		return x.Deltas
+	}
+	return nil
+}
+
+func (x *ModifyIssuesRequest) GetNotifyType() NotifyType {
+	if x != nil {
+		return x.NotifyType
+	}
+	return NotifyType_NOTIFY_TYPE_UNSPECIFIED
+}
+
+func (x *ModifyIssuesRequest) GetCommentContent() string {
+	if x != nil {
+		return x.CommentContent
+	}
+	return ""
+}
+
+func (x *ModifyIssuesRequest) GetUploads() []*AttachmentUpload {
+	if x != nil {
+		return x.Uploads
+	}
+	return nil
+}
+
+// The response message for Issues.ModifyIssues.
+// Next available tag: 2
+type ModifyIssuesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The updated issues.
+	Issues []*Issue `protobuf:"bytes,1,rep,name=issues,proto3" json:"issues,omitempty"`
+}
+
+func (x *ModifyIssuesResponse) Reset() {
+	*x = ModifyIssuesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[11]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ModifyIssuesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ModifyIssuesResponse) ProtoMessage() {}
+
+func (x *ModifyIssuesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[11]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ModifyIssuesResponse.ProtoReflect.Descriptor instead.
+func (*ModifyIssuesResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *ModifyIssuesResponse) GetIssues() []*Issue {
+	if x != nil {
+		return x.Issues
+	}
+	return nil
+}
+
+// The request message for Issues.ModifyIssueApprovalValues.
+// Next available tag: 4
+type ModifyIssueApprovalValuesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The ApprovalValue changes to make. Maximum of 100 deltas can be requested.
+	Deltas []*ApprovalDelta `protobuf:"bytes,1,rep,name=deltas,proto3" json:"deltas,omitempty"`
+	// The type of notification the modifications should trigger.
+	NotifyType NotifyType `protobuf:"varint,2,opt,name=notify_type,json=notifyType,proto3,enum=monorail.v3.NotifyType" json:"notify_type,omitempty"`
+	// The `content` of the Comment created for each change in `deltas`.
+	// Max length is 51200 characters.
+	CommentContent string `protobuf:"bytes,3,opt,name=comment_content,json=commentContent,proto3" json:"comment_content,omitempty"`
+}
+
+func (x *ModifyIssueApprovalValuesRequest) Reset() {
+	*x = ModifyIssueApprovalValuesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[12]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ModifyIssueApprovalValuesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ModifyIssueApprovalValuesRequest) ProtoMessage() {}
+
+func (x *ModifyIssueApprovalValuesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[12]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ModifyIssueApprovalValuesRequest.ProtoReflect.Descriptor instead.
+func (*ModifyIssueApprovalValuesRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *ModifyIssueApprovalValuesRequest) GetDeltas() []*ApprovalDelta {
+	if x != nil {
+		return x.Deltas
+	}
+	return nil
+}
+
+func (x *ModifyIssueApprovalValuesRequest) GetNotifyType() NotifyType {
+	if x != nil {
+		return x.NotifyType
+	}
+	return NotifyType_NOTIFY_TYPE_UNSPECIFIED
+}
+
+func (x *ModifyIssueApprovalValuesRequest) GetCommentContent() string {
+	if x != nil {
+		return x.CommentContent
+	}
+	return ""
+}
+
+// The response message for Issues.ModifyIssueApprovalValuesRequest.
+// Next available tag: 2
+type ModifyIssueApprovalValuesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The updated ApprovalValues.
+	ApprovalValues []*ApprovalValue `protobuf:"bytes,1,rep,name=approval_values,json=approvalValues,proto3" json:"approval_values,omitempty"`
+}
+
+func (x *ModifyIssueApprovalValuesResponse) Reset() {
+	*x = ModifyIssueApprovalValuesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[13]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ModifyIssueApprovalValuesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ModifyIssueApprovalValuesResponse) ProtoMessage() {}
+
+func (x *ModifyIssueApprovalValuesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[13]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ModifyIssueApprovalValuesResponse.ProtoReflect.Descriptor instead.
+func (*ModifyIssueApprovalValuesResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *ModifyIssueApprovalValuesResponse) GetApprovalValues() []*ApprovalValue {
+	if x != nil {
+		return x.ApprovalValues
+	}
+	return nil
+}
+
+// The request message for Issue.ListApprovalValues.
+// Next available tag: 2
+type ListApprovalValuesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the issue for which to list approval values.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+}
+
+func (x *ListApprovalValuesRequest) Reset() {
+	*x = ListApprovalValuesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[14]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListApprovalValuesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListApprovalValuesRequest) ProtoMessage() {}
+
+func (x *ListApprovalValuesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[14]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListApprovalValuesRequest.ProtoReflect.Descriptor instead.
+func (*ListApprovalValuesRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{14}
+}
+
+func (x *ListApprovalValuesRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+// The response message for Issues.ListApprovalValues.
+// Next available tag: 2
+type ListApprovalValuesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The approval values from the specified issue.
+	ApprovalValues []*ApprovalValue `protobuf:"bytes,1,rep,name=approval_values,json=approvalValues,proto3" json:"approval_values,omitempty"`
+}
+
+func (x *ListApprovalValuesResponse) Reset() {
+	*x = ListApprovalValuesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[15]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListApprovalValuesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListApprovalValuesResponse) ProtoMessage() {}
+
+func (x *ListApprovalValuesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[15]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListApprovalValuesResponse.ProtoReflect.Descriptor instead.
+func (*ListApprovalValuesResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{15}
+}
+
+func (x *ListApprovalValuesResponse) GetApprovalValues() []*ApprovalValue {
+	if x != nil {
+		return x.ApprovalValues
+	}
+	return nil
+}
+
+// The request message for Issues.ModifyCommentState.
+// Next available tag: 3
+type ModifyCommentStateRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the comment to modify state.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Requested state.
+	State IssueContentState `protobuf:"varint,2,opt,name=state,proto3,enum=monorail.v3.IssueContentState" json:"state,omitempty"`
+}
+
+func (x *ModifyCommentStateRequest) Reset() {
+	*x = ModifyCommentStateRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[16]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ModifyCommentStateRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ModifyCommentStateRequest) ProtoMessage() {}
+
+func (x *ModifyCommentStateRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[16]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ModifyCommentStateRequest.ProtoReflect.Descriptor instead.
+func (*ModifyCommentStateRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{16}
+}
+
+func (x *ModifyCommentStateRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ModifyCommentStateRequest) GetState() IssueContentState {
+	if x != nil {
+		return x.State
+	}
+	return IssueContentState_STATE_UNSPECIFIED
+}
+
+// The response message for Issues.ModifyCommentState.
+// Next available tag: 2
+type ModifyCommentStateResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The updated comment after modifying state.
+	Comment *Comment `protobuf:"bytes,1,opt,name=comment,proto3" json:"comment,omitempty"`
+}
+
+func (x *ModifyCommentStateResponse) Reset() {
+	*x = ModifyCommentStateResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[17]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ModifyCommentStateResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ModifyCommentStateResponse) ProtoMessage() {}
+
+func (x *ModifyCommentStateResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[17]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ModifyCommentStateResponse.ProtoReflect.Descriptor instead.
+func (*ModifyCommentStateResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{17}
+}
+
+func (x *ModifyCommentStateResponse) GetComment() *Comment {
+	if x != nil {
+		return x.Comment
+	}
+	return nil
+}
+
+// The request message for MakeIssueFromTemplate.
+// Next available tag: 5
+type MakeIssueFromTemplateRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the template to use for filling in default values
+	// and adding approvals and phases.
+	Template string `protobuf:"bytes,1,opt,name=template,proto3" json:"template,omitempty"`
+	// The issue differences relative to the `template.issue` default.
+	TemplateIssueDelta *IssueDelta `protobuf:"bytes,2,opt,name=template_issue_delta,json=templateIssueDelta,proto3" json:"template_issue_delta,omitempty"`
+	// Changes to fields belonging to approvals relative to template default.
+	// While ApprovalDelta can hold additional information, this method only
+	// allows adding and removing field values, all other deltas will be ignored.
+	TemplateApprovalDeltas []*ApprovalDelta `protobuf:"bytes,3,rep,name=template_approval_deltas,json=templateApprovalDeltas,proto3" json:"template_approval_deltas,omitempty"`
+	// The issue description, will be saved as the first comment.
+	Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"`
+}
+
+func (x *MakeIssueFromTemplateRequest) Reset() {
+	*x = MakeIssueFromTemplateRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[18]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MakeIssueFromTemplateRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MakeIssueFromTemplateRequest) ProtoMessage() {}
+
+func (x *MakeIssueFromTemplateRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[18]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MakeIssueFromTemplateRequest.ProtoReflect.Descriptor instead.
+func (*MakeIssueFromTemplateRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{18}
+}
+
+func (x *MakeIssueFromTemplateRequest) GetTemplate() string {
+	if x != nil {
+		return x.Template
+	}
+	return ""
+}
+
+func (x *MakeIssueFromTemplateRequest) GetTemplateIssueDelta() *IssueDelta {
+	if x != nil {
+		return x.TemplateIssueDelta
+	}
+	return nil
+}
+
+func (x *MakeIssueFromTemplateRequest) GetTemplateApprovalDeltas() []*ApprovalDelta {
+	if x != nil {
+		return x.TemplateApprovalDeltas
+	}
+	return nil
+}
+
+func (x *MakeIssueFromTemplateRequest) GetDescription() string {
+	if x != nil {
+		return x.Description
+	}
+	return ""
+}
+
+// The request message for MakeIssue.
+// Next available tag: 6
+type MakeIssueRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the project the issue should belong to.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The issue to be created.
+	Issue *Issue `protobuf:"bytes,2,opt,name=issue,proto3" json:"issue,omitempty"`
+	// The issue description.
+	Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"`
+	// The type of notification the creation should trigger.
+	NotifyType NotifyType `protobuf:"varint,4,opt,name=notify_type,json=notifyType,proto3,enum=monorail.v3.NotifyType" json:"notify_type,omitempty"`
+	// The attachment that will be attached to each new issue.
+	Uploads []*AttachmentUpload `protobuf:"bytes,5,rep,name=uploads,proto3" json:"uploads,omitempty"`
+}
+
+func (x *MakeIssueRequest) Reset() {
+	*x = MakeIssueRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[19]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MakeIssueRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MakeIssueRequest) ProtoMessage() {}
+
+func (x *MakeIssueRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[19]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MakeIssueRequest.ProtoReflect.Descriptor instead.
+func (*MakeIssueRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP(), []int{19}
+}
+
+func (x *MakeIssueRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *MakeIssueRequest) GetIssue() *Issue {
+	if x != nil {
+		return x.Issue
+	}
+	return nil
+}
+
+func (x *MakeIssueRequest) GetDescription() string {
+	if x != nil {
+		return x.Description
+	}
+	return ""
+}
+
+func (x *MakeIssueRequest) GetNotifyType() NotifyType {
+	if x != nil {
+		return x.NotifyType
+	}
+	return NotifyType_NOTIFY_TYPE_UNSPECIFIED
+}
+
+func (x *MakeIssueRequest) GetUploads() []*AttachmentUpload {
+	if x != nil {
+		return x.Uploads
+	}
+	return nil
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDesc = []byte{
+	0x0a, 0x4b, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x1a, 0x20, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65, 0x6c,
+	0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62,
+	0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72,
+	0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x52, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72,
+	0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61,
+	0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
+	0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61,
+	0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x6f,
+	0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x42, 0x0a, 0x0f,
+	0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+	0x2f, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1b, 0xfa,
+	0x41, 0x15, 0x0a, 0x13, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f,
+	0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75, 0x65, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x22, 0x7b, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75,
+	0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x06, 0x70, 0x61, 0x72,
+	0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x17, 0x0a, 0x15,
+	0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x2e, 0x0a,
+	0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x42, 0x18, 0xfa, 0x41,
+	0x15, 0x0a, 0x13, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x44, 0x0a,
+	0x16, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65,
+	0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x06, 0x69, 0x73, 0x73,
+	0x75, 0x65, 0x73, 0x22, 0xbd, 0x01, 0x0a, 0x13, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x39, 0x0a, 0x08, 0x70,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x42, 0x1d, 0xfa,
+	0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f,
+	0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x70, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12, 0x1b, 0x0a, 0x09,
+	0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52,
+	0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67,
+	0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70,
+	0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65,
+	0x72, 0x5f, 0x62, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65,
+	0x72, 0x42, 0x79, 0x22, 0x6a, 0x0a, 0x14, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x73, 0x73,
+	0x75, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x06, 0x69,
+	0x73, 0x73, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52,
+	0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f,
+	0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22,
+	0x9e, 0x01, 0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x33, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e,
+	0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1b, 0xfa, 0x41, 0x15, 0x0a, 0x13, 0x61, 0x70,
+	0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75,
+	0x65, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09,
+	0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52,
+	0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67,
+	0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70,
+	0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x6c, 0x74,
+	0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72,
+	0x22, 0x70, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d,
+	0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74,
+	0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65,
+	0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b,
+	0x65, 0x6e, 0x22, 0x52, 0x0a, 0x10, 0x41, 0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74,
+	0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1f, 0x0a, 0x08, 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61,
+	0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x66,
+	0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65,
+	0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x63,
+	0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x8a, 0x04, 0x0a, 0x0a, 0x49, 0x73, 0x73, 0x75, 0x65,
+	0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x2d, 0x0a, 0x05, 0x69, 0x73, 0x73, 0x75, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x05, 0x69,
+	0x73, 0x73, 0x75, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d,
+	0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c,
+	0x64, 0x4d, 0x61, 0x73, 0x6b, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61,
+	0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b, 0x12, 0x36, 0x0a, 0x0a, 0x63, 0x63, 0x73, 0x5f, 0x72, 0x65,
+	0x6d, 0x6f, 0x76, 0x65, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a,
+	0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55,
+	0x73, 0x65, 0x72, 0x52, 0x09, 0x63, 0x63, 0x73, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x4e,
+	0x0a, 0x18, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x6f, 0x6e, 0x5f, 0x69, 0x73, 0x73,
+	0x75, 0x65, 0x73, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x15, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x66, 0x52, 0x15, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64,
+	0x4f, 0x6e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x4b,
+	0x0a, 0x16, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65,
+	0x73, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73,
+	0x75, 0x65, 0x52, 0x65, 0x66, 0x52, 0x14, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x4c, 0x0a, 0x11, 0x63,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65,
+	0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x1f, 0xfa, 0x41, 0x1c, 0x0a, 0x1a, 0x61, 0x70, 0x69,
+	0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x43, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x10, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65,
+	0x6e, 0x74, 0x73, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6c, 0x61, 0x62,
+	0x65, 0x6c, 0x73, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x0c, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x43,
+	0x0a, 0x11, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x73, 0x5f, 0x72, 0x65, 0x6d,
+	0x6f, 0x76, 0x65, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x52, 0x0f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x73, 0x52, 0x65, 0x6d,
+	0x6f, 0x76, 0x65, 0x22, 0x9d, 0x02, 0x0a, 0x0d, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c,
+	0x44, 0x65, 0x6c, 0x74, 0x61, 0x12, 0x41, 0x0a, 0x0e, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61,
+	0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x70, 0x70, 0x72,
+	0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0d, 0x61, 0x70, 0x70, 0x72, 0x6f,
+	0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x40, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61,
+	0x74, 0x65, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x46, 0x69, 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0a,
+	0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b, 0x12, 0x42, 0x0a, 0x10, 0x61, 0x70,
+	0x70, 0x72, 0x6f, 0x76, 0x65, 0x72, 0x73, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x18, 0x03,
+	0x20, 0x03, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63,
+	0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x0f, 0x61,
+	0x70, 0x70, 0x72, 0x6f, 0x76, 0x65, 0x72, 0x73, 0x52, 0x65, 0x6d, 0x6f, 0x76, 0x65, 0x12, 0x43,
+	0x0a, 0x11, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x73, 0x5f, 0x72, 0x65, 0x6d,
+	0x6f, 0x76, 0x65, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x52, 0x0f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x73, 0x52, 0x65, 0x6d,
+	0x6f, 0x76, 0x65, 0x22, 0xe2, 0x01, 0x0a, 0x13, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2f, 0x0a, 0x06, 0x64,
+	0x65, 0x6c, 0x74, 0x61, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x44,
+	0x65, 0x6c, 0x74, 0x61, 0x52, 0x06, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x73, 0x12, 0x38, 0x0a, 0x0b,
+	0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x79, 0x70, 0x65, 0x52, 0x0a, 0x6e, 0x6f, 0x74, 0x69,
+	0x66, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x27, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e,
+	0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0e, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12,
+	0x37, 0x0a, 0x07, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x1d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41,
+	0x74, 0x74, 0x61, 0x63, 0x68, 0x6d, 0x65, 0x6e, 0x74, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52,
+	0x07, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x73, 0x22, 0x42, 0x0a, 0x14, 0x4d, 0x6f, 0x64, 0x69,
+	0x66, 0x79, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
+	0x12, 0x2a, 0x0a, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x52, 0x06, 0x69, 0x73, 0x73, 0x75, 0x65, 0x73, 0x22, 0xb9, 0x01, 0x0a,
+	0x20, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73, 0x73, 0x75, 0x65, 0x41, 0x70, 0x70, 0x72,
+	0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x12, 0x32, 0x0a, 0x06, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x52, 0x06, 0x64,
+	0x65, 0x6c, 0x74, 0x61, 0x73, 0x12, 0x38, 0x0a, 0x0b, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x5f,
+	0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54,
+	0x79, 0x70, 0x65, 0x52, 0x0a, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12,
+	0x27, 0x0a, 0x0f, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x6e, 0x74, 0x65,
+	0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e,
+	0x74, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x68, 0x0a, 0x21, 0x4d, 0x6f, 0x64, 0x69,
+	0x66, 0x79, 0x49, 0x73, 0x73, 0x75, 0x65, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56,
+	0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a,
+	0x0f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73,
+	0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x52, 0x0e, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75,
+	0x65, 0x73, 0x22, 0x50, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76,
+	0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12,
+	0x33, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x1b, 0xfa, 0x41, 0x15, 0x0a, 0x13, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75, 0x65, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x70, 0x61,
+	0x72, 0x65, 0x6e, 0x74, 0x22, 0x61, 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x72,
+	0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x43, 0x0a, 0x0f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x5f, 0x76,
+	0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76,
+	0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61,
+	0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x22, 0x84, 0x01, 0x0a, 0x19, 0x4d, 0x6f, 0x64, 0x69,
+	0x66, 0x79, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72,
+	0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0xe0,
+	0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x34, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74,
+	0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x43, 0x6f, 0x6e, 0x74, 0x65,
+	0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x4c,
+	0x0a, 0x1a, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x53,
+	0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x07,
+	0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x6d,
+	0x65, 0x6e, 0x74, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x22, 0x9a, 0x02, 0x0a,
+	0x1c, 0x4d, 0x61, 0x6b, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x46, 0x72, 0x6f, 0x6d, 0x54, 0x65,
+	0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x37, 0x0a,
+	0x08, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x1b, 0xfa, 0x41, 0x18, 0x0a, 0x16, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x08, 0x74, 0x65,
+	0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x49, 0x0a, 0x14, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61,
+	0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x52, 0x12, 0x74,
+	0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x44, 0x65, 0x6c, 0x74,
+	0x61, 0x12, 0x54, 0x0a, 0x18, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x70,
+	0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x5f, 0x64, 0x65, 0x6c, 0x74, 0x61, 0x73, 0x18, 0x03, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x52,
+	0x16, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61,
+	0x6c, 0x44, 0x65, 0x6c, 0x74, 0x61, 0x73, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72,
+	0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65,
+	0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x88, 0x02, 0x0a, 0x10, 0x4d, 0x61,
+	0x6b, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35,
+	0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d,
+	0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63,
+	0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x70,
+	0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x28, 0x0a, 0x05, 0x69, 0x73, 0x73, 0x75, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x05, 0x69, 0x73, 0x73, 0x75, 0x65, 0x12,
+	0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f,
+	0x6e, 0x12, 0x38, 0x0a, 0x0b, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x79, 0x70, 0x65,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x79, 0x70, 0x65, 0x52,
+	0x0a, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x79, 0x70, 0x65, 0x12, 0x37, 0x0a, 0x07, 0x75,
+	0x70, 0x6c, 0x6f, 0x61, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x74, 0x74, 0x61, 0x63,
+	0x68, 0x6d, 0x65, 0x6e, 0x74, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x07, 0x75, 0x70, 0x6c,
+	0x6f, 0x61, 0x64, 0x73, 0x2a, 0x49, 0x0a, 0x0a, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x79,
+	0x70, 0x65, 0x12, 0x1b, 0x0a, 0x17, 0x4e, 0x4f, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x54, 0x59, 0x50,
+	0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12,
+	0x09, 0x0a, 0x05, 0x45, 0x4d, 0x41, 0x49, 0x4c, 0x10, 0x01, 0x12, 0x13, 0x0a, 0x0f, 0x4e, 0x4f,
+	0x5f, 0x4e, 0x4f, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x02, 0x32,
+	0x96, 0x07, 0x0a, 0x06, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x08, 0x47, 0x65,
+	0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x12, 0x1c, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x0e, 0x42, 0x61,
+	0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x12, 0x22, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68,
+	0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x23, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x42,
+	0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x65, 0x61, 0x72, 0x63,
+	0x68, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73, 0x12, 0x20, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x73, 0x73, 0x75,
+	0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x55,
+	0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x20,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73,
+	0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c,
+	0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0c, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x73, 0x12, 0x20, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73, 0x73, 0x75, 0x65, 0x73,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73, 0x73, 0x75,
+	0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x7c, 0x0a, 0x19,
+	0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73, 0x73, 0x75, 0x65, 0x41, 0x70, 0x70, 0x72, 0x6f,
+	0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x2d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x49, 0x73, 0x73,
+	0x75, 0x65, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x67, 0x0a, 0x12, 0x4c, 0x69,
+	0x73, 0x74, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73,
+	0x12, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c,
+	0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x70, 0x70, 0x72, 0x6f,
+	0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x22, 0x00, 0x12, 0x67, 0x0a, 0x12, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x6d,
+	0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x43, 0x6f,
+	0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x1a, 0x27, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x4d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x43, 0x6f, 0x6d, 0x6d, 0x65, 0x6e, 0x74, 0x53, 0x74, 0x61,
+	0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x15,
+	0x4d, 0x61, 0x6b, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x46, 0x72, 0x6f, 0x6d, 0x54, 0x65, 0x6d,
+	0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x29, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x4d, 0x61, 0x6b, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x46, 0x72, 0x6f,
+	0x6d, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x22, 0x00, 0x12, 0x40, 0x0a, 0x09, 0x4d, 0x61, 0x6b, 0x65, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x12, 0x1d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x4d, 0x61, 0x6b, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x12, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x22, 0x00, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63,
+	0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69,
+	0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes = make([]protoimpl.MessageInfo, 20)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_goTypes = []interface{}{
+	(NotifyType)(0),                           // 0: monorail.v3.NotifyType
+	(*GetIssueRequest)(nil),                   // 1: monorail.v3.GetIssueRequest
+	(*BatchGetIssuesRequest)(nil),             // 2: monorail.v3.BatchGetIssuesRequest
+	(*BatchGetIssuesResponse)(nil),            // 3: monorail.v3.BatchGetIssuesResponse
+	(*SearchIssuesRequest)(nil),               // 4: monorail.v3.SearchIssuesRequest
+	(*SearchIssuesResponse)(nil),              // 5: monorail.v3.SearchIssuesResponse
+	(*ListCommentsRequest)(nil),               // 6: monorail.v3.ListCommentsRequest
+	(*ListCommentsResponse)(nil),              // 7: monorail.v3.ListCommentsResponse
+	(*AttachmentUpload)(nil),                  // 8: monorail.v3.AttachmentUpload
+	(*IssueDelta)(nil),                        // 9: monorail.v3.IssueDelta
+	(*ApprovalDelta)(nil),                     // 10: monorail.v3.ApprovalDelta
+	(*ModifyIssuesRequest)(nil),               // 11: monorail.v3.ModifyIssuesRequest
+	(*ModifyIssuesResponse)(nil),              // 12: monorail.v3.ModifyIssuesResponse
+	(*ModifyIssueApprovalValuesRequest)(nil),  // 13: monorail.v3.ModifyIssueApprovalValuesRequest
+	(*ModifyIssueApprovalValuesResponse)(nil), // 14: monorail.v3.ModifyIssueApprovalValuesResponse
+	(*ListApprovalValuesRequest)(nil),         // 15: monorail.v3.ListApprovalValuesRequest
+	(*ListApprovalValuesResponse)(nil),        // 16: monorail.v3.ListApprovalValuesResponse
+	(*ModifyCommentStateRequest)(nil),         // 17: monorail.v3.ModifyCommentStateRequest
+	(*ModifyCommentStateResponse)(nil),        // 18: monorail.v3.ModifyCommentStateResponse
+	(*MakeIssueFromTemplateRequest)(nil),      // 19: monorail.v3.MakeIssueFromTemplateRequest
+	(*MakeIssueRequest)(nil),                  // 20: monorail.v3.MakeIssueRequest
+	(*Issue)(nil),                             // 21: monorail.v3.Issue
+	(*Comment)(nil),                           // 22: monorail.v3.Comment
+	(*fieldmaskpb.FieldMask)(nil),             // 23: google.protobuf.FieldMask
+	(*IssueRef)(nil),                          // 24: monorail.v3.IssueRef
+	(*FieldValue)(nil),                        // 25: monorail.v3.FieldValue
+	(*ApprovalValue)(nil),                     // 26: monorail.v3.ApprovalValue
+	(IssueContentState)(0),                    // 27: monorail.v3.IssueContentState
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_depIdxs = []int32{
+	21, // 0: monorail.v3.BatchGetIssuesResponse.issues:type_name -> monorail.v3.Issue
+	21, // 1: monorail.v3.SearchIssuesResponse.issues:type_name -> monorail.v3.Issue
+	22, // 2: monorail.v3.ListCommentsResponse.comments:type_name -> monorail.v3.Comment
+	21, // 3: monorail.v3.IssueDelta.issue:type_name -> monorail.v3.Issue
+	23, // 4: monorail.v3.IssueDelta.update_mask:type_name -> google.protobuf.FieldMask
+	24, // 5: monorail.v3.IssueDelta.blocked_on_issues_remove:type_name -> monorail.v3.IssueRef
+	24, // 6: monorail.v3.IssueDelta.blocking_issues_remove:type_name -> monorail.v3.IssueRef
+	25, // 7: monorail.v3.IssueDelta.field_vals_remove:type_name -> monorail.v3.FieldValue
+	26, // 8: monorail.v3.ApprovalDelta.approval_value:type_name -> monorail.v3.ApprovalValue
+	23, // 9: monorail.v3.ApprovalDelta.update_mask:type_name -> google.protobuf.FieldMask
+	25, // 10: monorail.v3.ApprovalDelta.field_vals_remove:type_name -> monorail.v3.FieldValue
+	9,  // 11: monorail.v3.ModifyIssuesRequest.deltas:type_name -> monorail.v3.IssueDelta
+	0,  // 12: monorail.v3.ModifyIssuesRequest.notify_type:type_name -> monorail.v3.NotifyType
+	8,  // 13: monorail.v3.ModifyIssuesRequest.uploads:type_name -> monorail.v3.AttachmentUpload
+	21, // 14: monorail.v3.ModifyIssuesResponse.issues:type_name -> monorail.v3.Issue
+	10, // 15: monorail.v3.ModifyIssueApprovalValuesRequest.deltas:type_name -> monorail.v3.ApprovalDelta
+	0,  // 16: monorail.v3.ModifyIssueApprovalValuesRequest.notify_type:type_name -> monorail.v3.NotifyType
+	26, // 17: monorail.v3.ModifyIssueApprovalValuesResponse.approval_values:type_name -> monorail.v3.ApprovalValue
+	26, // 18: monorail.v3.ListApprovalValuesResponse.approval_values:type_name -> monorail.v3.ApprovalValue
+	27, // 19: monorail.v3.ModifyCommentStateRequest.state:type_name -> monorail.v3.IssueContentState
+	22, // 20: monorail.v3.ModifyCommentStateResponse.comment:type_name -> monorail.v3.Comment
+	9,  // 21: monorail.v3.MakeIssueFromTemplateRequest.template_issue_delta:type_name -> monorail.v3.IssueDelta
+	10, // 22: monorail.v3.MakeIssueFromTemplateRequest.template_approval_deltas:type_name -> monorail.v3.ApprovalDelta
+	21, // 23: monorail.v3.MakeIssueRequest.issue:type_name -> monorail.v3.Issue
+	0,  // 24: monorail.v3.MakeIssueRequest.notify_type:type_name -> monorail.v3.NotifyType
+	8,  // 25: monorail.v3.MakeIssueRequest.uploads:type_name -> monorail.v3.AttachmentUpload
+	1,  // 26: monorail.v3.Issues.GetIssue:input_type -> monorail.v3.GetIssueRequest
+	2,  // 27: monorail.v3.Issues.BatchGetIssues:input_type -> monorail.v3.BatchGetIssuesRequest
+	4,  // 28: monorail.v3.Issues.SearchIssues:input_type -> monorail.v3.SearchIssuesRequest
+	6,  // 29: monorail.v3.Issues.ListComments:input_type -> monorail.v3.ListCommentsRequest
+	11, // 30: monorail.v3.Issues.ModifyIssues:input_type -> monorail.v3.ModifyIssuesRequest
+	13, // 31: monorail.v3.Issues.ModifyIssueApprovalValues:input_type -> monorail.v3.ModifyIssueApprovalValuesRequest
+	15, // 32: monorail.v3.Issues.ListApprovalValues:input_type -> monorail.v3.ListApprovalValuesRequest
+	17, // 33: monorail.v3.Issues.ModifyCommentState:input_type -> monorail.v3.ModifyCommentStateRequest
+	19, // 34: monorail.v3.Issues.MakeIssueFromTemplate:input_type -> monorail.v3.MakeIssueFromTemplateRequest
+	20, // 35: monorail.v3.Issues.MakeIssue:input_type -> monorail.v3.MakeIssueRequest
+	21, // 36: monorail.v3.Issues.GetIssue:output_type -> monorail.v3.Issue
+	3,  // 37: monorail.v3.Issues.BatchGetIssues:output_type -> monorail.v3.BatchGetIssuesResponse
+	5,  // 38: monorail.v3.Issues.SearchIssues:output_type -> monorail.v3.SearchIssuesResponse
+	7,  // 39: monorail.v3.Issues.ListComments:output_type -> monorail.v3.ListCommentsResponse
+	12, // 40: monorail.v3.Issues.ModifyIssues:output_type -> monorail.v3.ModifyIssuesResponse
+	14, // 41: monorail.v3.Issues.ModifyIssueApprovalValues:output_type -> monorail.v3.ModifyIssueApprovalValuesResponse
+	16, // 42: monorail.v3.Issues.ListApprovalValues:output_type -> monorail.v3.ListApprovalValuesResponse
+	18, // 43: monorail.v3.Issues.ModifyCommentState:output_type -> monorail.v3.ModifyCommentStateResponse
+	21, // 44: monorail.v3.Issues.MakeIssueFromTemplate:output_type -> monorail.v3.Issue
+	21, // 45: monorail.v3.Issues.MakeIssue:output_type -> monorail.v3.Issue
+	36, // [36:46] is the sub-list for method output_type
+	26, // [26:36] is the sub-list for method input_type
+	26, // [26:26] is the sub-list for extension type_name
+	26, // [26:26] is the sub-list for extension extendee
+	0,  // [0:26] is the sub-list for field type_name
+}
+
+func init() { file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_init() }
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetIssueRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetIssuesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetIssuesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SearchIssuesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*SearchIssuesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListCommentsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListCommentsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*AttachmentUpload); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IssueDelta); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ApprovalDelta); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ModifyIssuesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ModifyIssuesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ModifyIssueApprovalValuesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ModifyIssueApprovalValuesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListApprovalValuesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListApprovalValuesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ModifyCommentStateRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ModifyCommentStateResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MakeIssueFromTemplateRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MakeIssueRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDesc,
+			NumEnums:      1,
+			NumMessages:   20,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_depIdxs,
+		EnumInfos:         file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_enumTypes,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issues_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// IssuesClient is the client API for Issues service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type IssuesClient interface {
+	// status: ALPHA
+	// Returns the requested Issue.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if `name` is formatted incorrectly.
+	//   NOT_FOUND if the issue does not exist.
+	//   PERMISSION_DENIED if the requester is not allowed to view the issue.
+	GetIssue(ctx context.Context, in *GetIssueRequest, opts ...grpc.CallOption) (*Issue, error)
+	// status: ALPHA
+	// Returns the requested Issues.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if `names` is formatted incorrectly. Or if a parent
+	//       collection in `names` does not match the value in `parent`.
+	//   NOT_FOUND if any of the given issues do not exist.
+	//   PERMISSION_DENIED if the requester does not have permission to view one
+	//       (or more) of the given issues.
+	BatchGetIssues(ctx context.Context, in *BatchGetIssuesRequest, opts ...grpc.CallOption) (*BatchGetIssuesResponse, error)
+	// status: ALPHA
+	// Searches over issues within the specified projects.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if project names or search query are invalid.
+	SearchIssues(ctx context.Context, in *SearchIssuesRequest, opts ...grpc.CallOption) (*SearchIssuesResponse, error)
+	// status: ALPHA
+	// Lists comments for an issue.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if `parent` is formatted incorrectly or `page_size` < 0.
+	//   NOT_FOUND if `parent` does not exist.
+	//   PERMISSION_DENIED if the requester is not allowed to view `parent`.
+	ListComments(ctx context.Context, in *ListCommentsRequest, opts ...grpc.CallOption) (*ListCommentsResponse, error)
+	// status: ALPHA
+	// Modifies Issues and creates a new Comment for each.
+	// Issues with NOOP changes and no comment_content will not be updated
+	// and will not be included in the response.
+	// We do not offer a standard UpdateIssue because every issue change
+	// must result in the side-effect of creating a new Comment, and may result in
+	// the side effect of sending a notification. We also want to allow for any
+	// combination of issue changes to be made at once in a monolithic method.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT required fields are missing or fields are formatted
+	//     incorrectly.
+	//   NOT_FOUND if any specified issues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to make the
+	//     requested change.
+	ModifyIssues(ctx context.Context, in *ModifyIssuesRequest, opts ...grpc.CallOption) (*ModifyIssuesResponse, error)
+	// status: ALPHA
+	// Modifies ApprovalValues and creates a new Comment for each delta.
+	// We do not offer a standard UpdateApprovalValue because changes result
+	// in creating Comments on the parent Issue, and may have the side effect of
+	// sending notifications. We also want to allow for any combination of
+	// approval changes to be made at once in a monolithic method.
+	// To modify owner add 'owner' to update_mask, though 'owner.user' works too.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT required fields are missing or fields are formatted
+	//     incorrectly.
+	//   NOT_FOUND if any specified ApprovalValues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to make any of the
+	//     requested changes.
+	ModifyIssueApprovalValues(ctx context.Context, in *ModifyIssueApprovalValuesRequest, opts ...grpc.CallOption) (*ModifyIssueApprovalValuesResponse, error)
+	// status: ALPHA
+	// Lists approval values for an issue.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if request `parent` is formatted incorrectly.
+	//   NOT_FOUND if the parent issue does not exist.
+	//   PERMISSION_DENIED if the requester is not allowed to view parent issue.
+	ListApprovalValues(ctx context.Context, in *ListApprovalValuesRequest, opts ...grpc.CallOption) (*ListApprovalValuesResponse, error)
+	// status: NOT READY
+	// Changes state for a comment. Supported state transitions:
+	//   - ACTIVE -> DELETED
+	//   - ACTIVE -> SPAM
+	//   - DELETED -> ACTIVE
+	//   - SPAM -> ACTIVE
+	//
+	// Raises:
+	//   TODO(crbug/monorail/7867): Document errors when implemented
+	ModifyCommentState(ctx context.Context, in *ModifyCommentStateRequest, opts ...grpc.CallOption) (*ModifyCommentStateResponse, error)
+	// status: NOT READY
+	// Makes an issue from an IssueTemplate and deltas.
+	//
+	// Raises:
+	//   TODO(crbug/monorail/7197): Document errors when implemented
+	MakeIssueFromTemplate(ctx context.Context, in *MakeIssueFromTemplateRequest, opts ...grpc.CallOption) (*Issue, error)
+	// status: ALPHA
+	// Makes a basic issue, does not support phases, approvals, or approval
+	// fields.
+	// We do not offer a standard CreateIssue because Issue descriptions are
+	// required, but not included in the Issue proto.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if any given names does not have a valid format, if any
+	//     fields in the requested issue were invalid, or if proposed values
+	//     violates filter rules that should error.
+	//   NOT_FOUND if no project exists with the given name.
+	//   PERMISSION_DENIED if user lacks sufficient permissions.
+	MakeIssue(ctx context.Context, in *MakeIssueRequest, opts ...grpc.CallOption) (*Issue, error)
+}
+type issuesPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewIssuesPRPCClient(client *prpc.Client) IssuesClient {
+	return &issuesPRPCClient{client}
+}
+
+func (c *issuesPRPCClient) GetIssue(ctx context.Context, in *GetIssueRequest, opts ...grpc.CallOption) (*Issue, error) {
+	out := new(Issue)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "GetIssue", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) BatchGetIssues(ctx context.Context, in *BatchGetIssuesRequest, opts ...grpc.CallOption) (*BatchGetIssuesResponse, error) {
+	out := new(BatchGetIssuesResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "BatchGetIssues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) SearchIssues(ctx context.Context, in *SearchIssuesRequest, opts ...grpc.CallOption) (*SearchIssuesResponse, error) {
+	out := new(SearchIssuesResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "SearchIssues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) ListComments(ctx context.Context, in *ListCommentsRequest, opts ...grpc.CallOption) (*ListCommentsResponse, error) {
+	out := new(ListCommentsResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "ListComments", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) ModifyIssues(ctx context.Context, in *ModifyIssuesRequest, opts ...grpc.CallOption) (*ModifyIssuesResponse, error) {
+	out := new(ModifyIssuesResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "ModifyIssues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) ModifyIssueApprovalValues(ctx context.Context, in *ModifyIssueApprovalValuesRequest, opts ...grpc.CallOption) (*ModifyIssueApprovalValuesResponse, error) {
+	out := new(ModifyIssueApprovalValuesResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "ModifyIssueApprovalValues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) ListApprovalValues(ctx context.Context, in *ListApprovalValuesRequest, opts ...grpc.CallOption) (*ListApprovalValuesResponse, error) {
+	out := new(ListApprovalValuesResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "ListApprovalValues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) ModifyCommentState(ctx context.Context, in *ModifyCommentStateRequest, opts ...grpc.CallOption) (*ModifyCommentStateResponse, error) {
+	out := new(ModifyCommentStateResponse)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "ModifyCommentState", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) MakeIssueFromTemplate(ctx context.Context, in *MakeIssueFromTemplateRequest, opts ...grpc.CallOption) (*Issue, error) {
+	out := new(Issue)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "MakeIssueFromTemplate", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesPRPCClient) MakeIssue(ctx context.Context, in *MakeIssueRequest, opts ...grpc.CallOption) (*Issue, error) {
+	out := new(Issue)
+	err := c.client.Call(ctx, "monorail.v3.Issues", "MakeIssue", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type issuesClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewIssuesClient(cc grpc.ClientConnInterface) IssuesClient {
+	return &issuesClient{cc}
+}
+
+func (c *issuesClient) GetIssue(ctx context.Context, in *GetIssueRequest, opts ...grpc.CallOption) (*Issue, error) {
+	out := new(Issue)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/GetIssue", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) BatchGetIssues(ctx context.Context, in *BatchGetIssuesRequest, opts ...grpc.CallOption) (*BatchGetIssuesResponse, error) {
+	out := new(BatchGetIssuesResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/BatchGetIssues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) SearchIssues(ctx context.Context, in *SearchIssuesRequest, opts ...grpc.CallOption) (*SearchIssuesResponse, error) {
+	out := new(SearchIssuesResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/SearchIssues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) ListComments(ctx context.Context, in *ListCommentsRequest, opts ...grpc.CallOption) (*ListCommentsResponse, error) {
+	out := new(ListCommentsResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/ListComments", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) ModifyIssues(ctx context.Context, in *ModifyIssuesRequest, opts ...grpc.CallOption) (*ModifyIssuesResponse, error) {
+	out := new(ModifyIssuesResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/ModifyIssues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) ModifyIssueApprovalValues(ctx context.Context, in *ModifyIssueApprovalValuesRequest, opts ...grpc.CallOption) (*ModifyIssueApprovalValuesResponse, error) {
+	out := new(ModifyIssueApprovalValuesResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/ModifyIssueApprovalValues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) ListApprovalValues(ctx context.Context, in *ListApprovalValuesRequest, opts ...grpc.CallOption) (*ListApprovalValuesResponse, error) {
+	out := new(ListApprovalValuesResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/ListApprovalValues", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) ModifyCommentState(ctx context.Context, in *ModifyCommentStateRequest, opts ...grpc.CallOption) (*ModifyCommentStateResponse, error) {
+	out := new(ModifyCommentStateResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/ModifyCommentState", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) MakeIssueFromTemplate(ctx context.Context, in *MakeIssueFromTemplateRequest, opts ...grpc.CallOption) (*Issue, error) {
+	out := new(Issue)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/MakeIssueFromTemplate", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *issuesClient) MakeIssue(ctx context.Context, in *MakeIssueRequest, opts ...grpc.CallOption) (*Issue, error) {
+	out := new(Issue)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Issues/MakeIssue", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// IssuesServer is the server API for Issues service.
+type IssuesServer interface {
+	// status: ALPHA
+	// Returns the requested Issue.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if `name` is formatted incorrectly.
+	//   NOT_FOUND if the issue does not exist.
+	//   PERMISSION_DENIED if the requester is not allowed to view the issue.
+	GetIssue(context.Context, *GetIssueRequest) (*Issue, error)
+	// status: ALPHA
+	// Returns the requested Issues.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if `names` is formatted incorrectly. Or if a parent
+	//       collection in `names` does not match the value in `parent`.
+	//   NOT_FOUND if any of the given issues do not exist.
+	//   PERMISSION_DENIED if the requester does not have permission to view one
+	//       (or more) of the given issues.
+	BatchGetIssues(context.Context, *BatchGetIssuesRequest) (*BatchGetIssuesResponse, error)
+	// status: ALPHA
+	// Searches over issues within the specified projects.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if project names or search query are invalid.
+	SearchIssues(context.Context, *SearchIssuesRequest) (*SearchIssuesResponse, error)
+	// status: ALPHA
+	// Lists comments for an issue.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if `parent` is formatted incorrectly or `page_size` < 0.
+	//   NOT_FOUND if `parent` does not exist.
+	//   PERMISSION_DENIED if the requester is not allowed to view `parent`.
+	ListComments(context.Context, *ListCommentsRequest) (*ListCommentsResponse, error)
+	// status: ALPHA
+	// Modifies Issues and creates a new Comment for each.
+	// Issues with NOOP changes and no comment_content will not be updated
+	// and will not be included in the response.
+	// We do not offer a standard UpdateIssue because every issue change
+	// must result in the side-effect of creating a new Comment, and may result in
+	// the side effect of sending a notification. We also want to allow for any
+	// combination of issue changes to be made at once in a monolithic method.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT required fields are missing or fields are formatted
+	//     incorrectly.
+	//   NOT_FOUND if any specified issues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to make the
+	//     requested change.
+	ModifyIssues(context.Context, *ModifyIssuesRequest) (*ModifyIssuesResponse, error)
+	// status: ALPHA
+	// Modifies ApprovalValues and creates a new Comment for each delta.
+	// We do not offer a standard UpdateApprovalValue because changes result
+	// in creating Comments on the parent Issue, and may have the side effect of
+	// sending notifications. We also want to allow for any combination of
+	// approval changes to be made at once in a monolithic method.
+	// To modify owner add 'owner' to update_mask, though 'owner.user' works too.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT required fields are missing or fields are formatted
+	//     incorrectly.
+	//   NOT_FOUND if any specified ApprovalValues are not found.
+	//   PERMISSION_DENIED if the requester is not allowed to make any of the
+	//     requested changes.
+	ModifyIssueApprovalValues(context.Context, *ModifyIssueApprovalValuesRequest) (*ModifyIssueApprovalValuesResponse, error)
+	// status: ALPHA
+	// Lists approval values for an issue.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if request `parent` is formatted incorrectly.
+	//   NOT_FOUND if the parent issue does not exist.
+	//   PERMISSION_DENIED if the requester is not allowed to view parent issue.
+	ListApprovalValues(context.Context, *ListApprovalValuesRequest) (*ListApprovalValuesResponse, error)
+	// status: NOT READY
+	// Changes state for a comment. Supported state transitions:
+	//   - ACTIVE -> DELETED
+	//   - ACTIVE -> SPAM
+	//   - DELETED -> ACTIVE
+	//   - SPAM -> ACTIVE
+	//
+	// Raises:
+	//   TODO(crbug/monorail/7867): Document errors when implemented
+	ModifyCommentState(context.Context, *ModifyCommentStateRequest) (*ModifyCommentStateResponse, error)
+	// status: NOT READY
+	// Makes an issue from an IssueTemplate and deltas.
+	//
+	// Raises:
+	//   TODO(crbug/monorail/7197): Document errors when implemented
+	MakeIssueFromTemplate(context.Context, *MakeIssueFromTemplateRequest) (*Issue, error)
+	// status: ALPHA
+	// Makes a basic issue, does not support phases, approvals, or approval
+	// fields.
+	// We do not offer a standard CreateIssue because Issue descriptions are
+	// required, but not included in the Issue proto.
+	//
+	// Raises:
+	//   INVALID_ARGUMENT if any given names does not have a valid format, if any
+	//     fields in the requested issue were invalid, or if proposed values
+	//     violates filter rules that should error.
+	//   NOT_FOUND if no project exists with the given name.
+	//   PERMISSION_DENIED if user lacks sufficient permissions.
+	MakeIssue(context.Context, *MakeIssueRequest) (*Issue, error)
+}
+
+// UnimplementedIssuesServer can be embedded to have forward compatible implementations.
+type UnimplementedIssuesServer struct {
+}
+
+func (*UnimplementedIssuesServer) GetIssue(context.Context, *GetIssueRequest) (*Issue, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetIssue not implemented")
+}
+func (*UnimplementedIssuesServer) BatchGetIssues(context.Context, *BatchGetIssuesRequest) (*BatchGetIssuesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method BatchGetIssues not implemented")
+}
+func (*UnimplementedIssuesServer) SearchIssues(context.Context, *SearchIssuesRequest) (*SearchIssuesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method SearchIssues not implemented")
+}
+func (*UnimplementedIssuesServer) ListComments(context.Context, *ListCommentsRequest) (*ListCommentsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListComments not implemented")
+}
+func (*UnimplementedIssuesServer) ModifyIssues(context.Context, *ModifyIssuesRequest) (*ModifyIssuesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ModifyIssues not implemented")
+}
+func (*UnimplementedIssuesServer) ModifyIssueApprovalValues(context.Context, *ModifyIssueApprovalValuesRequest) (*ModifyIssueApprovalValuesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ModifyIssueApprovalValues not implemented")
+}
+func (*UnimplementedIssuesServer) ListApprovalValues(context.Context, *ListApprovalValuesRequest) (*ListApprovalValuesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListApprovalValues not implemented")
+}
+func (*UnimplementedIssuesServer) ModifyCommentState(context.Context, *ModifyCommentStateRequest) (*ModifyCommentStateResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ModifyCommentState not implemented")
+}
+func (*UnimplementedIssuesServer) MakeIssueFromTemplate(context.Context, *MakeIssueFromTemplateRequest) (*Issue, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method MakeIssueFromTemplate not implemented")
+}
+func (*UnimplementedIssuesServer) MakeIssue(context.Context, *MakeIssueRequest) (*Issue, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method MakeIssue not implemented")
+}
+
+func RegisterIssuesServer(s prpc.Registrar, srv IssuesServer) {
+	s.RegisterService(&_Issues_serviceDesc, srv)
+}
+
+func _Issues_GetIssue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetIssueRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).GetIssue(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/GetIssue",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).GetIssue(ctx, req.(*GetIssueRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_BatchGetIssues_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(BatchGetIssuesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).BatchGetIssues(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/BatchGetIssues",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).BatchGetIssues(ctx, req.(*BatchGetIssuesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_SearchIssues_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(SearchIssuesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).SearchIssues(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/SearchIssues",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).SearchIssues(ctx, req.(*SearchIssuesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_ListComments_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListCommentsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).ListComments(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/ListComments",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).ListComments(ctx, req.(*ListCommentsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_ModifyIssues_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ModifyIssuesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).ModifyIssues(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/ModifyIssues",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).ModifyIssues(ctx, req.(*ModifyIssuesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_ModifyIssueApprovalValues_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ModifyIssueApprovalValuesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).ModifyIssueApprovalValues(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/ModifyIssueApprovalValues",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).ModifyIssueApprovalValues(ctx, req.(*ModifyIssueApprovalValuesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_ListApprovalValues_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListApprovalValuesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).ListApprovalValues(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/ListApprovalValues",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).ListApprovalValues(ctx, req.(*ListApprovalValuesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_ModifyCommentState_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ModifyCommentStateRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).ModifyCommentState(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/ModifyCommentState",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).ModifyCommentState(ctx, req.(*ModifyCommentStateRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_MakeIssueFromTemplate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(MakeIssueFromTemplateRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).MakeIssueFromTemplate(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/MakeIssueFromTemplate",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).MakeIssueFromTemplate(ctx, req.(*MakeIssueFromTemplateRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Issues_MakeIssue_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(MakeIssueRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(IssuesServer).MakeIssue(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Issues/MakeIssue",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(IssuesServer).MakeIssue(ctx, req.(*MakeIssueRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Issues_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "monorail.v3.Issues",
+	HandlerType: (*IssuesServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetIssue",
+			Handler:    _Issues_GetIssue_Handler,
+		},
+		{
+			MethodName: "BatchGetIssues",
+			Handler:    _Issues_BatchGetIssues_Handler,
+		},
+		{
+			MethodName: "SearchIssues",
+			Handler:    _Issues_SearchIssues_Handler,
+		},
+		{
+			MethodName: "ListComments",
+			Handler:    _Issues_ListComments_Handler,
+		},
+		{
+			MethodName: "ModifyIssues",
+			Handler:    _Issues_ModifyIssues_Handler,
+		},
+		{
+			MethodName: "ModifyIssueApprovalValues",
+			Handler:    _Issues_ModifyIssueApprovalValues_Handler,
+		},
+		{
+			MethodName: "ListApprovalValues",
+			Handler:    _Issues_ListApprovalValues_Handler,
+		},
+		{
+			MethodName: "ModifyCommentState",
+			Handler:    _Issues_ModifyCommentState_Handler,
+		},
+		{
+			MethodName: "MakeIssueFromTemplate",
+			Handler:    _Issues_MakeIssueFromTemplate_Handler,
+		},
+		{
+			MethodName: "MakeIssue",
+			Handler:    _Issues_MakeIssue_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/issues.proto",
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/issues.proto b/analysis/internal/bugs/monorail/api_proto/issues.proto
new file mode 100644
index 0000000..177fd21
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/issues.proto
@@ -0,0 +1,463 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/protobuf/field_mask.proto";
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/issue_objects.proto";
+
+// ***ONLY CALL rpcs WITH `status: {ALPHA|STABLE}`***
+// rpcs without `status` are not ready.
+
+// Issues service includes all methods needed for managing Issues.
+service Issues {
+  // status: ALPHA
+  // Returns the requested Issue.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if `name` is formatted incorrectly.
+  //   NOT_FOUND if the issue does not exist.
+  //   PERMISSION_DENIED if the requester is not allowed to view the issue.
+  rpc GetIssue (GetIssueRequest) returns (Issue) {}
+
+  // status: ALPHA
+  // Returns the requested Issues.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if `names` is formatted incorrectly. Or if a parent
+  //       collection in `names` does not match the value in `parent`.
+  //   NOT_FOUND if any of the given issues do not exist.
+  //   PERMISSION_DENIED if the requester does not have permission to view one
+  //       (or more) of the given issues.
+  rpc BatchGetIssues(BatchGetIssuesRequest) returns (BatchGetIssuesResponse) {}
+
+  // status: ALPHA
+  // Searches over issues within the specified projects.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if project names or search query are invalid.
+  rpc SearchIssues (SearchIssuesRequest) returns (SearchIssuesResponse) {}
+
+  // status: ALPHA
+  // Lists comments for an issue.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if `parent` is formatted incorrectly or `page_size` < 0.
+  //   NOT_FOUND if `parent` does not exist.
+  //   PERMISSION_DENIED if the requester is not allowed to view `parent`.
+  rpc ListComments (ListCommentsRequest) returns (ListCommentsResponse) {}
+
+  // status: ALPHA
+  // Modifies Issues and creates a new Comment for each.
+  // Issues with NOOP changes and no comment_content will not be updated
+  // and will not be included in the response.
+  // We do not offer a standard UpdateIssue because every issue change
+  // must result in the side-effect of creating a new Comment, and may result in
+  // the side effect of sending a notification. We also want to allow for any
+  // combination of issue changes to be made at once in a monolithic method.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT required fields are missing or fields are formatted
+  //     incorrectly.
+  //   NOT_FOUND if any specified issues are not found.
+  //   PERMISSION_DENIED if the requester is not allowed to make the
+  //     requested change.
+  rpc ModifyIssues (ModifyIssuesRequest) returns (ModifyIssuesResponse) {}
+
+  // status: ALPHA
+  // Modifies ApprovalValues and creates a new Comment for each delta.
+  // We do not offer a standard UpdateApprovalValue because changes result
+  // in creating Comments on the parent Issue, and may have the side effect of
+  // sending notifications. We also want to allow for any combination of
+  // approval changes to be made at once in a monolithic method.
+  // To modify owner add 'owner' to update_mask, though 'owner.user' works too.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT required fields are missing or fields are formatted
+  //     incorrectly.
+  //   NOT_FOUND if any specified ApprovalValues are not found.
+  //   PERMISSION_DENIED if the requester is not allowed to make any of the
+  //     requested changes.
+  rpc ModifyIssueApprovalValues (ModifyIssueApprovalValuesRequest) returns
+      (ModifyIssueApprovalValuesResponse) {}
+
+  // status: ALPHA
+  // Lists approval values for an issue.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if request `parent` is formatted incorrectly.
+  //   NOT_FOUND if the parent issue does not exist.
+  //   PERMISSION_DENIED if the requester is not allowed to view parent issue.
+  rpc ListApprovalValues (ListApprovalValuesRequest) returns
+      (ListApprovalValuesResponse) {}
+
+  // status: NOT READY
+  // Changes state for a comment. Supported state transitions:
+  //   - ACTIVE -> DELETED
+  //   - ACTIVE -> SPAM
+  //   - DELETED -> ACTIVE
+  //   - SPAM -> ACTIVE
+  //
+  // Raises:
+  //   TODO(crbug/monorail/7867): Document errors when implemented
+  rpc ModifyCommentState (ModifyCommentStateRequest) returns
+      (ModifyCommentStateResponse) {}
+
+  // status: NOT READY
+  // Makes an issue from an IssueTemplate and deltas.
+  //
+  // Raises:
+  //   TODO(crbug/monorail/7197): Document errors when implemented
+  rpc MakeIssueFromTemplate (MakeIssueFromTemplateRequest) returns (Issue) {}
+
+  // status: ALPHA
+  // Makes a basic issue, does not support phases, approvals, or approval
+  // fields.
+  // We do not offer a standard CreateIssue because Issue descriptions are
+  // required, but not included in the Issue proto.
+  //
+  // Raises:
+  //   INVALID_ARGUMENT if any given names does not have a valid format, if any
+  //     fields in the requested issue were invalid, or if proposed values
+  //     violates filter rules that should error.
+  //   NOT_FOUND if no project exists with the given name.
+  //   PERMISSION_DENIED if user lacks sufficient permissions.
+  rpc MakeIssue (MakeIssueRequest) returns (Issue) {}
+}
+
+
+// The request message for Issues.GetIssue.
+// Next available tag: 2
+message GetIssueRequest {
+  // The name of the issue to request.
+  string name = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/Issue"},
+      (google.api.field_behavior) = REQUIRED ];
+}
+
+// The request message for Issues.BatchGetIssues.
+// Next available tag: 3
+message BatchGetIssuesRequest {
+  // The project name from which to batch get issues. If included, the parent
+  // of all the issues specified in `names` must match this field.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Project"} ];
+  // The issues to request. Maximum of 100 can be retrieved.
+  repeated string names = 2 [
+      (google.api.resource_reference) = {type: "api.crbug.com/Issue"} ];
+}
+
+// The response message for Issues.BatchGetIssues.
+// Next available tag: 2
+message BatchGetIssuesResponse {
+  // Issues matching the given request.
+  repeated Issue issues = 1;
+}
+
+// The request message for Issues.SearchIssues.
+// Next available tag: 6
+message SearchIssuesRequest {
+  // The names of Projects in which to search issues.
+  repeated string projects = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+    (google.api.field_behavior) = REQUIRED ];
+  // The query string can contain any number of free text and
+  // field search expressions.
+  // Please see https://bugs.chromium.org/p/chromium/issues/searchtips for more
+  // details of how the query string works.
+  //
+  // Canned queries have been deprecated in v3 in favor of search scoping using
+  // parentheses support.
+  // For clients who previously used canned queries, we're providing the
+  // mapping of legacy canned query IDs to Monorail search syntax:
+  //   - Format: (can_id, description, query_string)
+  //   - (1, 'All issues', '')
+  //   - (2, 'Open issues', 'is:open')
+  //   - (3, 'Open and owned by me', 'is:open owner:me')
+  //   - (4, 'Open and reported by me', 'is:open reporter:me')
+  //   - (5, 'Open and starred by me', 'is:open is:starred')
+  //   - (6, 'New issues', 'status:new')
+  //   - (7, 'Issues to verify', 'status=fixed,done')
+  //   - (8, 'Open with comment by me', 'is:open commentby:me')
+  string query = 2;
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  // If unspecified, at most 100 items will be returned.
+  // The maximum value is 100; values above 100 will be coerced to 100.
+  int32 page_size = 3;
+  // A page token, received from a previous `SearchIssues` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `SearchIssues` must match
+  // the call that provided the page token.
+  string page_token = 4;
+  // The string of comma separated field names used to order the items.
+  // Adding '-' before a field, reverses the sort order.
+  // E.g. 'stars,-status' sorts the items by number of stars, high to low,
+  // then by status, low to high.
+  string order_by = 5;
+}
+
+// The response message for Issues.SearchIssues.
+// Next available tag: 3
+message SearchIssuesResponse {
+  // Issues matching the given request.
+  repeated Issue issues = 1;
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// The request message for Issues.ListComments.
+// Next available tag: 5
+message ListCommentsRequest {
+  // The name of the issue for which to list comments.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Issue"},
+    (google.api.field_behavior) = REQUIRED ];
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  // If unspecified, at most 100 items will be returned.
+  // The maximum value is 100; values above 100 will be coerced to 100.
+  int32 page_size = 2;
+  // A page token, received from a previous `ListComments` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListComments` must
+  // match the call that provided the page token.
+  string page_token = 3;
+  // For our initial release this filter only supports filtering to comments
+  // related to a specific approval.
+  // For example `approval = "projects/monorail/approvalDefs/1"`,
+  // Note that no further logical or comparison operators are supported
+  string filter = 4;
+}
+
+// The response message for Issues.ListComments
+// Next available tag: 3
+message ListCommentsResponse {
+  // The comments from the specified issue.
+  repeated Comment comments = 1;
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// An attachment to upload to a comment or description.
+// Next available tag: 3
+message AttachmentUpload {
+  string filename = 1 [ (google.api.field_behavior) = REQUIRED ];
+  bytes content = 2 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+// Holds changes to one issue, used in ModifyIssuesRequest.
+// Next available tag: 9
+message IssueDelta {
+  // The issue's `name` field is used to identify the issue to be
+  // updated. `issue.name` must always be filled.
+  //
+  // Values with rule-based Derivation within `issue` and in `field_vals_remove`
+  // will be ignored.
+  Issue issue = 1 [
+      (google.api.field_behavior) = REQUIRED ];
+  // The list of fields in `issue` to be updated.
+  //
+  // Repeated fields set on `issue` will be appended to.
+  //
+  // Non-repeated fields (e.g. `owner`) can be set with `issue.owner` set and
+  // either 'owner' or 'owner.user' added to `update_mask`.
+  // To unset non-repeated fields back to their default value, `issue.owner`
+  // must contain the default value and `update_mask` must include 'owner.user'
+  // NOT 'owner'.
+  //
+  // Its `field_values`, however, are a special case. Fields can be specified as
+  // single-value or multi-value in their FieldDef.
+  //
+  // Single-value Field: if there is preexisting FieldValue with the same
+  // `field` and `phase`, it will be REPLACED.
+  //
+  // Multi-value Field: a new value will be appended, unless the same `field`,
+  // `phase`, `value` combination already exists. In that case, the FieldValue
+  // will be ignored. In other words, duplicate values are ignored.
+  // (With the exception of crbug.com/monorail/8137 until it is fixed).
+  google.protobuf.FieldMask update_mask = 2 [
+      (google.api.field_behavior) = REQUIRED ];
+
+  // Values to remove from the repeated fields of the issue.
+
+  // Cc's to remove.
+  repeated string ccs_remove = 3 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"}];
+  // Blocked_on issues to remove.
+  repeated IssueRef blocked_on_issues_remove = 4;
+  // Blocking issues to remove.
+  repeated IssueRef blocking_issues_remove = 5;
+  // Components to remove.
+  repeated string components_remove = 6 [
+      (google.api.resource_reference) = {type: "api.crbug.com/ComponentDef"}];
+  // Labels to remove.
+  repeated string labels_remove = 7;
+  // FieldValues to remove. Any values that did not already exist will be
+  // ignored e.g. if you append a FieldValue in issue and remove it here, it
+  // will still be added.
+  repeated FieldValue field_vals_remove = 8;
+
+  // TODO(crbug.com/monorail/8019): add Attachment uploading and removing.
+}
+
+// Changes to make to an ApprovalValue. Used to ModifyIssueApprovalValues or
+// to MakeIssueFromTemplate.
+//
+// NOTE: The same handling of FieldValues discussed in IssueDelta applies here.
+// Next available tag: 6
+message ApprovalDelta {
+  // The ApprovalValue we want to update. `approval_value.name` must always be
+  // set.
+  ApprovalValue approval_value = 1;
+  // Repeated fields found in `update_mask` will be appended to.
+  google.protobuf.FieldMask update_mask = 2 [
+      (google.api.field_behavior) = REQUIRED ];
+  // Resource names of the approvers we want to remove.
+  repeated string approvers_remove = 3 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+  // FieldValues that do not belong to `approval_value` will trigger error.
+  repeated FieldValue field_vals_remove = 5;
+  // TODO(crbug.com/monorail/8019): add Attachment uploading and removing.
+}
+
+
+// The type of notification a change should trigger.
+// See monorail/doc/userguide/email.md
+// Next available tag: 2
+enum NotifyType {
+  // The default value. This value is unused.
+  NOTIFY_TYPE_UNSPECIFIED = 0;
+  // An email notification should be sent.
+  EMAIL = 1;
+  // No notifcation should be triggered at all.
+  NO_NOTIFICATION = 2;
+}
+
+
+// The request message for Issues.ModifyIssues.
+// Next available tag: 5
+message ModifyIssuesRequest {
+  // The issue changes to make. A maximum of 100 issue changes can be requested.
+  // There is also a constraint of 50 additional 'impacted issues' per
+  // ModifyIssuesRequest. 'Impacted issues' are issues that are adding/removing
+  // `blocked_on`, `blocking`, or `merge`
+  // If you encounter this error, consider significantly smaller batches.
+  repeated IssueDelta deltas = 1;
+  // The type of notification the modifications should trigger.
+  NotifyType notify_type = 2;
+  // The comment text that should be added to each issue in delta.
+  // Max length is 51200 characters.
+  string comment_content = 3;
+  // The attachment that will be to each comment for each issue in delta.
+  repeated AttachmentUpload uploads = 4;
+}
+
+
+// The response message for Issues.ModifyIssues.
+// Next available tag: 2
+message ModifyIssuesResponse {
+  // The updated issues.
+  repeated Issue issues = 1;
+}
+
+// The request message for Issues.ModifyIssueApprovalValues.
+// Next available tag: 4
+message ModifyIssueApprovalValuesRequest {
+  // The ApprovalValue changes to make. Maximum of 100 deltas can be requested.
+  repeated ApprovalDelta deltas = 1;
+  // The type of notification the modifications should trigger.
+  NotifyType notify_type = 2;
+  // The `content` of the Comment created for each change in `deltas`.
+  // Max length is 51200 characters.
+  string comment_content = 3;
+}
+
+// The response message for Issues.ModifyIssueApprovalValuesRequest.
+// Next available tag: 2
+message ModifyIssueApprovalValuesResponse {
+  // The updated ApprovalValues.
+  repeated ApprovalValue approval_values = 1;
+}
+
+// The request message for Issue.ListApprovalValues.
+// Next available tag: 2
+message ListApprovalValuesRequest {
+  // The name of the issue for which to list approval values.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Issue"},
+    (google.api.field_behavior) = REQUIRED ];
+}
+
+// The response message for Issues.ListApprovalValues.
+// Next available tag: 2
+message ListApprovalValuesResponse {
+  // The approval values from the specified issue.
+  repeated ApprovalValue approval_values = 1;
+}
+
+// The request message for Issues.ModifyCommentState.
+// Next available tag: 3
+message ModifyCommentStateRequest {
+  // Resource name of the comment to modify state.
+  string name = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Comment"},
+    (google.api.field_behavior) = REQUIRED ];
+  // Requested state.
+  IssueContentState state = 2;
+}
+
+// The response message for Issues.ModifyCommentState.
+// Next available tag: 2
+message ModifyCommentStateResponse {
+  // The updated comment after modifying state.
+  Comment comment = 1;
+}
+
+// The request message for MakeIssueFromTemplate.
+// Next available tag: 5
+message MakeIssueFromTemplateRequest {
+  // Resource name of the template to use for filling in default values
+  // and adding approvals and phases.
+  string template = 1 [
+      (google.api.resource_reference) = { type: "api.crbug.com/Template" }
+  ];
+  // The issue differences relative to the `template.issue` default.
+  IssueDelta template_issue_delta = 2;
+  // Changes to fields belonging to approvals relative to template default.
+  // While ApprovalDelta can hold additional information, this method only
+  // allows adding and removing field values, all other deltas will be ignored.
+  repeated ApprovalDelta template_approval_deltas = 3;
+  // The issue description, will be saved as the first comment.
+  string description = 4;
+}
+
+// The request message for MakeIssue.
+// Next available tag: 6
+message MakeIssueRequest {
+  // The name of the project the issue should belong to.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+    (google.api.field_behavior) = REQUIRED ];
+  // The issue to be created.
+  Issue issue = 2;
+  // The issue description.
+  string description = 3;
+  // The type of notification the creation should trigger.
+  NotifyType notify_type = 4;
+  // The attachment that will be attached to each new issue.
+  repeated AttachmentUpload uploads = 5;
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/pb.discovery.go b/analysis/internal/bugs/monorail/api_proto/pb.discovery.go
new file mode 100644
index 0000000..ce61d4b
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/pb.discovery.go
@@ -0,0 +1,4958 @@
+// Code generated by cproto. DO NOT EDIT.
+
+package api_proto
+
+import "go.chromium.org/luci/grpc/discovery"
+
+import "google.golang.org/protobuf/types/descriptorpb"
+
+func init() {
+	discovery.RegisterDescriptorSetCompressed(
+		[]string{
+			"monorail.v3.Frontend", "monorail.v3.Hotlists", "monorail.v3.Issues", "monorail.v3.Permissions", "monorail.v3.Projects", "monorail.v3.Users",
+		},
+		[]byte{31, 139,
+			8, 0, 0, 0, 0, 0, 0, 255, 236, 189, 107, 120, 92, 201,
+			117, 24, 136, 251, 234, 110, 20, 94, 141, 2, 134, 4, 155, 175,
+			98, 147, 28, 60, 216, 104, 114, 56, 111, 82, 163, 9, 8, 52,
+			57, 61, 2, 1, 186, 1, 104, 68, 41, 26, 204, 69, 119, 1,
+			184, 195, 238, 123, 91, 247, 222, 38, 6, 19, 41, 158, 88, 145,
+			181, 86, 36, 71, 138, 44, 89, 142, 158, 145, 173, 216, 35, 107,
+			51, 177, 236, 245, 35, 107, 173, 252, 136, 227, 216, 250, 172, 36,
+			94, 199, 235, 88, 89, 219, 43, 91, 178, 98, 173, 21, 89, 227,
+			135, 44, 75, 246, 126, 231, 212, 227, 222, 110, 128, 51, 148, 172,
+			113, 126, 108, 248, 241, 251, 208, 167, 110, 61, 79, 157, 58, 117,
+			206, 169, 83, 167, 200, 175, 188, 201, 34, 108, 43, 8, 182, 154,
+			252, 108, 59, 12, 226, 96, 163, 179, 121, 182, 193, 163, 122, 232,
+			181, 227, 32, 44, 99, 26, 29, 17, 57, 202, 42, 71, 241, 42,
+			25, 189, 236, 53, 249, 130, 206, 184, 194, 99, 250, 0, 177, 55,
+			189, 38, 159, 48, 152, 53, 53, 112, 254, 84, 185, 167, 80, 185,
+			187, 196, 53, 72, 174, 97, 137, 226, 31, 216, 100, 108, 159, 175,
+			148, 18, 219, 119, 91, 80, 163, 49, 213, 95, 195, 223, 116, 130,
+			100, 219, 110, 253, 134, 187, 197, 39, 76, 76, 86, 32, 61, 70,
+			72, 131, 183, 185, 223, 224, 126, 125, 119, 194, 98, 214, 84, 127,
+			45, 149, 66, 207, 144, 209, 118, 103, 163, 233, 213, 215, 83, 217,
+			8, 179, 166, 156, 90, 94, 124, 88, 72, 50, 79, 146, 145, 29,
+			238, 222, 72, 103, 29, 192, 172, 195, 144, 156, 202, 56, 79, 6,
+			91, 60, 138, 220, 45, 190, 30, 239, 182, 249, 132, 141, 163, 103,
+			123, 70, 223, 59, 242, 1, 89, 106, 117, 183, 205, 233, 28, 233,
+			231, 126, 167, 37, 106, 112, 110, 129, 191, 138, 223, 105, 245, 214,
+			146, 131, 98, 178, 138, 108, 196, 195, 155, 94, 157, 79, 100, 176,
+			130, 201, 61, 21, 172, 136, 239, 189, 117, 168, 114, 116, 158, 244,
+			243, 167, 98, 238, 71, 94, 224, 79, 100, 177, 146, 211, 251, 204,
+			34, 111, 54, 122, 171, 72, 202, 209, 251, 72, 54, 104, 199, 94,
+			224, 71, 19, 57, 102, 76, 13, 156, 63, 178, 47, 33, 44, 139,
+			60, 53, 149, 153, 86, 73, 62, 10, 58, 97, 157, 175, 215, 131,
+			6, 95, 247, 252, 205, 96, 162, 31, 43, 56, 190, 119, 32, 152,
+			113, 62, 104, 240, 170, 191, 25, 212, 134, 163, 46, 152, 30, 32,
+			153, 104, 215, 143, 221, 167, 38, 6, 145, 66, 36, 84, 252, 153,
+			12, 25, 185, 29, 18, 187, 72, 156, 77, 24, 229, 132, 249, 205,
+			224, 64, 148, 233, 70, 98, 230, 91, 68, 226, 28, 25, 240, 121,
+			20, 243, 134, 160, 8, 235, 54, 105, 138, 136, 66, 123, 73, 202,
+			254, 150, 72, 234, 85, 100, 68, 119, 105, 61, 116, 253, 45, 69,
+			155, 103, 95, 172, 39, 229, 138, 42, 87, 131, 98, 181, 97, 222,
+			5, 211, 5, 66, 2, 159, 7, 155, 235, 13, 94, 111, 78, 228,
+			110, 129, 165, 101, 200, 178, 7, 75, 129, 72, 173, 55, 233, 131,
+			9, 169, 101, 111, 65, 41, 87, 197, 34, 219, 67, 109, 107, 100,
+			56, 228, 64, 247, 188, 33, 71, 214, 143, 157, 40, 191, 232, 200,
+			106, 178, 152, 24, 216, 80, 152, 6, 233, 73, 162, 19, 214, 145,
+			172, 8, 114, 161, 65, 149, 184, 228, 182, 120, 225, 105, 50, 220,
+			141, 30, 58, 78, 156, 40, 118, 195, 24, 169, 208, 169, 9, 128,
+			230, 137, 197, 253, 6, 114, 57, 167, 6, 63, 233, 223, 75, 6,
+			108, 225, 128, 239, 220, 59, 163, 93, 53, 247, 142, 187, 112, 63,
+			25, 234, 26, 192, 237, 54, 93, 124, 61, 185, 99, 223, 170, 233,
+			171, 200, 120, 199, 247, 252, 152, 135, 237, 144, 3, 197, 138, 166,
+			38, 254, 48, 123, 11, 154, 91, 75, 231, 22, 181, 212, 198, 58,
+			123, 19, 103, 250, 115, 95, 200, 230, 159, 121, 230, 153, 103, 204,
+			226, 207, 103, 200, 248, 126, 107, 102, 223, 229, 123, 128, 100, 252,
+			78, 107, 131, 135, 136, 36, 167, 38, 33, 58, 71, 156, 166, 187,
+			193, 155, 19, 54, 51, 166, 134, 207, 159, 185, 173, 85, 89, 94,
+			132, 34, 53, 81, 146, 190, 156, 216, 146, 69, 67, 13, 51, 183,
+			87, 3, 172, 165, 26, 150, 163, 135, 73, 63, 252, 21, 180, 145,
+			193, 62, 231, 32, 1, 232, 130, 22, 72, 14, 151, 73, 131, 171,
+			173, 77, 195, 64, 88, 13, 190, 233, 118, 154, 241, 250, 77, 183,
+			217, 225, 72, 240, 253, 181, 65, 153, 248, 74, 72, 163, 199, 201,
+			128, 88, 85, 158, 223, 224, 79, 33, 247, 116, 106, 98, 161, 85,
+			33, 5, 154, 127, 50, 10, 124, 69, 154, 216, 4, 36, 96, 243,
+			247, 247, 50, 238, 163, 251, 15, 111, 207, 90, 154, 36, 35, 152,
+			227, 110, 57, 245, 110, 115, 98, 148, 25, 83, 185, 218, 176, 72,
+			94, 150, 169, 197, 159, 52, 137, 141, 140, 101, 132, 12, 172, 94,
+			191, 86, 89, 95, 88, 94, 187, 180, 88, 201, 27, 116, 152, 16,
+			76, 184, 188, 184, 60, 183, 154, 55, 53, 92, 93, 90, 189, 239,
+			158, 188, 165, 11, 172, 137, 4, 59, 157, 225, 238, 243, 121, 135,
+			230, 201, 160, 168, 160, 250, 170, 202, 194, 125, 247, 228, 51, 221,
+			41, 119, 159, 207, 103, 233, 16, 233, 199, 148, 75, 203, 203, 139,
+			249, 156, 174, 115, 101, 181, 86, 93, 186, 146, 239, 215, 117, 94,
+			169, 45, 175, 93, 203, 19, 93, 195, 213, 202, 202, 202, 220, 149,
+			74, 126, 64, 231, 184, 116, 125, 181, 178, 146, 31, 236, 234, 214,
+			221, 231, 243, 67, 186, 137, 202, 210, 218, 213, 252, 48, 29, 37,
+			67, 162, 9, 213, 137, 145, 158, 164, 251, 238, 201, 231, 147, 142,
+			136, 90, 70, 187, 18, 238, 187, 39, 79, 139, 243, 196, 65, 50,
+			164, 148, 12, 47, 206, 93, 170, 44, 174, 47, 95, 91, 173, 46,
+			47, 205, 45, 230, 141, 36, 173, 86, 249, 142, 181, 106, 173, 178,
+			144, 55, 211, 105, 215, 42, 115, 171, 149, 133, 188, 85, 172, 147,
+			241, 253, 24, 234, 190, 75, 40, 69, 11, 230, 45, 104, 1, 235,
+			234, 165, 133, 226, 239, 155, 100, 108, 159, 77, 101, 223, 70, 30,
+			38, 142, 160, 101, 177, 205, 78, 239, 187, 59, 33, 101, 239, 217,
+			106, 177, 92, 90, 212, 176, 110, 33, 106, 64, 21, 123, 8, 246,
+			181, 123, 152, 191, 216, 31, 239, 187, 157, 253, 17, 211, 190, 185,
+			77, 192, 217, 103, 19, 184, 72, 70, 247, 84, 116, 219, 204, 248,
+			141, 6, 153, 184, 21, 114, 94, 132, 37, 154, 93, 44, 241, 98,
+			47, 6, 79, 220, 122, 18, 246, 204, 245, 15, 27, 228, 192, 254,
+			34, 229, 190, 125, 120, 57, 201, 180, 120, 188, 29, 40, 177, 106,
+			239, 222, 117, 21, 63, 247, 78, 182, 44, 149, 222, 237, 173, 91,
+			201, 133, 162, 55, 123, 122, 250, 61, 38, 185, 99, 223, 202, 247,
+			237, 232, 81, 66, 60, 191, 221, 137, 133, 232, 36, 56, 113, 63,
+			166, 32, 243, 2, 46, 219, 137, 245, 119, 11, 191, 19, 145, 132,
+			25, 30, 72, 58, 106, 99, 71, 143, 221, 98, 164, 123, 8, 243,
+			28, 201, 215, 155, 30, 247, 227, 245, 40, 14, 185, 219, 242, 252,
+			45, 220, 106, 114, 23, 156, 77, 183, 25, 241, 218, 136, 248, 188,
+			162, 190, 66, 9, 36, 160, 48, 85, 34, 211, 85, 66, 124, 214,
+			37, 138, 239, 232, 39, 3, 41, 1, 156, 158, 32, 131, 79, 186,
+			55, 221, 117, 165, 84, 9, 76, 12, 64, 218, 53, 169, 88, 157,
+			35, 227, 152, 37, 232, 196, 60, 92, 175, 55, 221, 40, 66, 164,
+			229, 48, 43, 133, 111, 203, 240, 105, 94, 125, 161, 247, 146, 49,
+			44, 209, 234, 52, 99, 175, 221, 228, 235, 160, 230, 69, 184, 229,
+			232, 158, 141, 66, 142, 171, 50, 3, 244, 40, 162, 11, 228, 40,
+			22, 219, 226, 62, 15, 221, 152, 175, 243, 215, 117, 220, 102, 180,
+			238, 250, 141, 245, 109, 55, 218, 158, 24, 135, 10, 46, 153, 19,
+			70, 237, 16, 100, 188, 34, 243, 85, 48, 219, 156, 223, 120, 196,
+			141, 182, 233, 5, 114, 0, 107, 137, 226, 208, 243, 183, 214, 235,
+			219, 188, 126, 99, 189, 19, 111, 62, 48, 113, 56, 221, 62, 246,
+			112, 5, 243, 204, 67, 150, 181, 120, 243, 1, 186, 66, 6, 97,
+			50, 90, 222, 211, 124, 125, 51, 8, 113, 15, 29, 222, 135, 53,
+			165, 48, 88, 94, 150, 5, 174, 6, 13, 126, 193, 89, 185, 86,
+			169, 44, 212, 6, 84, 45, 151, 131, 16, 8, 106, 43, 208, 8,
+			30, 16, 4, 181, 21, 40, 244, 222, 75, 198, 234, 117, 49, 102,
+			175, 190, 46, 149, 177, 104, 34, 223, 133, 172, 122, 253, 138, 200,
+			32, 105, 60, 162, 15, 146, 59, 18, 100, 165, 11, 142, 238, 25,
+			101, 111, 209, 123, 201, 88, 123, 119, 111, 65, 218, 213, 98, 123,
+			183, 183, 216, 253, 100, 188, 189, 221, 222, 91, 110, 38, 93, 142,
+			182, 183, 219, 189, 5, 79, 163, 102, 30, 242, 186, 27, 243, 198,
+			196, 193, 116, 246, 212, 7, 90, 38, 249, 122, 125, 157, 251, 238,
+			70, 147, 175, 187, 33, 247, 221, 104, 226, 56, 102, 182, 227, 176,
+			195, 107, 195, 245, 122, 5, 63, 206, 225, 55, 58, 67, 70, 131,
+			141, 39, 235, 130, 34, 215, 219, 33, 223, 244, 158, 154, 56, 133,
+			232, 29, 129, 15, 72, 143, 215, 48, 153, 78, 147, 124, 61, 218,
+			118, 195, 54, 178, 228, 168, 237, 214, 249, 196, 105, 145, 85, 164,
+			47, 169, 100, 88, 17, 209, 142, 183, 25, 171, 26, 39, 197, 138,
+			192, 52, 89, 219, 20, 201, 3, 38, 186, 26, 158, 194, 108, 195,
+			237, 237, 118, 186, 221, 147, 100, 8, 114, 38, 141, 78, 11, 193,
+			173, 189, 157, 106, 241, 30, 114, 0, 50, 181, 120, 236, 54, 220,
+			216, 77, 229, 46, 97, 110, 64, 251, 85, 249, 177, 171, 159, 97,
+			103, 99, 87, 19, 214, 172, 232, 39, 164, 41, 210, 122, 201, 132,
+			243, 226, 5, 50, 152, 166, 123, 218, 79, 4, 229, 231, 13, 16,
+			130, 230, 151, 23, 64, 124, 121, 117, 37, 111, 130, 24, 181, 88,
+			93, 173, 172, 215, 214, 150, 86, 171, 87, 43, 121, 43, 37, 216,
+			63, 106, 231, 238, 204, 79, 130, 212, 48, 220, 173, 169, 209, 151,
+			145, 131, 202, 172, 18, 241, 120, 125, 199, 11, 113, 65, 182, 92,
+			177, 57, 106, 250, 25, 151, 185, 86, 120, 252, 152, 23, 194, 114,
+			107, 185, 49, 93, 36, 199, 253, 96, 61, 138, 93, 191, 225, 134,
+			141, 245, 196, 160, 181, 238, 214, 235, 60, 138, 2, 177, 17, 234,
+			90, 142, 248, 193, 138, 204, 156, 236, 16, 115, 50, 107, 15, 249,
+			90, 183, 34, 223, 195, 164, 191, 229, 182, 215, 185, 31, 135, 187,
+			40, 159, 231, 106, 185, 150, 219, 174, 0, 252, 119, 162, 38, 61,
+			106, 231, 236, 188, 243, 168, 157, 115, 242, 153, 71, 237, 92, 38,
+			159, 125, 212, 206, 229, 242, 253, 143, 218, 185, 254, 60, 41, 126,
+			214, 34, 131, 105, 9, 30, 20, 162, 58, 238, 97, 6, 114, 185,
+			147, 47, 40, 239, 151, 231, 97, 115, 187, 144, 17, 226, 114, 77,
+			148, 4, 193, 2, 200, 143, 11, 241, 36, 87, 147, 16, 189, 66,
+			50, 79, 70, 88, 119, 6, 235, 222, 207, 26, 152, 170, 251, 209,
+			21, 172, 188, 255, 209, 149, 245, 165, 229, 218, 213, 185, 197, 154,
+			44, 78, 15, 17, 187, 233, 62, 189, 219, 189, 13, 98, 210, 237,
+			78, 203, 33, 98, 239, 112, 247, 70, 247, 230, 131, 73, 47, 225,
+			242, 56, 75, 28, 196, 23, 37, 68, 98, 44, 223, 71, 115, 196,
+			158, 95, 174, 193, 18, 201, 147, 65, 145, 186, 126, 173, 90, 153,
+			175, 228, 205, 226, 189, 36, 35, 144, 0, 203, 71, 163, 33, 223,
+			39, 65, 89, 135, 161, 190, 174, 93, 189, 84, 169, 229, 205, 61,
+			147, 95, 140, 200, 96, 90, 50, 255, 187, 81, 207, 127, 206, 32,
+			3, 41, 73, 27, 68, 36, 183, 217, 12, 118, 214, 221, 166, 231,
+			70, 146, 52, 8, 38, 205, 65, 202, 237, 78, 221, 223, 209, 162,
+			113, 242, 153, 226, 7, 12, 146, 239, 21, 117, 123, 186, 105, 252,
+			143, 236, 102, 241, 125, 6, 25, 238, 150, 111, 123, 186, 119, 226,
+			127, 104, 247, 126, 207, 36, 67, 93, 82, 237, 237, 246, 238, 117,
+			100, 212, 107, 240, 86, 59, 136, 185, 95, 223, 93, 111, 242, 155,
+			188, 57, 81, 68, 166, 177, 215, 204, 216, 213, 66, 185, 154, 148,
+			91, 132, 98, 23, 198, 170, 11, 149, 171, 215, 150, 87, 43, 75,
+			243, 215, 215, 215, 150, 94, 177, 180, 252, 216, 82, 45, 239, 245,
+			100, 123, 9, 151, 253, 53, 146, 239, 237, 20, 61, 72, 246, 235,
+			86, 190, 143, 142, 145, 145, 165, 229, 245, 149, 234, 66, 101, 189,
+			114, 249, 114, 101, 126, 117, 69, 88, 66, 116, 238, 213, 174, 5,
+			94, 252, 231, 22, 25, 219, 167, 39, 116, 78, 234, 48, 66, 173,
+			154, 189, 157, 222, 151, 65, 138, 184, 230, 134, 177, 84, 121, 166,
+			9, 96, 201, 143, 189, 77, 143, 135, 210, 194, 36, 20, 155, 145,
+			36, 93, 24, 153, 74, 132, 182, 131, 200, 139, 189, 155, 124, 221,
+			243, 149, 57, 10, 20, 29, 187, 150, 87, 95, 170, 126, 172, 115,
+			251, 124, 203, 237, 201, 13, 204, 220, 170, 229, 213, 23, 157, 251,
+			4, 25, 108, 4, 29, 144, 254, 68, 62, 216, 59, 140, 218, 128,
+			72, 211, 89, 164, 92, 159, 216, 193, 6, 107, 3, 34, 77, 100,
+			153, 36, 35, 238, 214, 86, 8, 149, 171, 138, 132, 166, 50, 172,
+			147, 49, 99, 225, 81, 146, 83, 120, 128, 205, 27, 48, 177, 222,
+			22, 234, 183, 57, 213, 95, 203, 249, 234, 227, 9, 50, 232, 69,
+			235, 137, 89, 223, 100, 230, 84, 174, 54, 224, 69, 218, 36, 90,
+			252, 97, 147, 12, 119, 31, 75, 208, 5, 146, 107, 6, 117, 23,
+			73, 75, 156, 137, 77, 189, 200, 73, 70, 121, 81, 230, 175, 233,
+			146, 133, 79, 27, 36, 167, 146, 233, 1, 98, 183, 221, 120, 27,
+			171, 115, 46, 153, 121, 163, 134, 48, 164, 71, 109, 215, 71, 18,
+			144, 233, 0, 195, 188, 54, 185, 219, 64, 53, 40, 104, 181, 184,
+			31, 71, 106, 94, 101, 250, 188, 76, 166, 103, 200, 104, 28, 186,
+			94, 179, 43, 175, 141, 121, 243, 234, 131, 206, 124, 129, 28, 82,
+			245, 54, 120, 236, 214, 183, 121, 35, 41, 148, 65, 115, 199, 65,
+			153, 97, 65, 126, 87, 101, 139, 255, 201, 32, 163, 74, 113, 107,
+			104, 100, 93, 37, 196, 245, 253, 32, 78, 163, 107, 47, 41, 239,
+			41, 87, 158, 211, 133, 106, 169, 10, 10, 45, 66, 146, 47, 183,
+			68, 219, 113, 50, 32, 207, 156, 240, 224, 82, 168, 250, 68, 36,
+			129, 134, 71, 199, 137, 179, 193, 183, 60, 95, 90, 146, 5, 160,
+			12, 50, 182, 54, 200, 92, 250, 135, 100, 172, 30, 180, 122, 187,
+			123, 41, 223, 99, 110, 136, 30, 49, 94, 61, 43, 51, 109, 5,
+			77, 215, 223, 42, 7, 225, 86, 114, 240, 10, 18, 79, 148, 58,
+			126, 109, 111, 124, 213, 48, 62, 108, 90, 87, 174, 93, 250, 168,
+			89, 184, 34, 10, 94, 83, 200, 168, 241, 205, 38, 175, 195, 0,
+			31, 253, 174, 95, 53, 73, 150, 58, 147, 125, 223, 155, 53, 200,
+			71, 71, 136, 49, 72, 173, 201, 62, 122, 254, 231, 7, 25, 102,
+			175, 7, 77, 118, 169, 179, 185, 201, 195, 136, 205, 50, 81, 209,
+			100, 196, 64, 159, 96, 200, 28, 234, 219, 174, 191, 197, 153, 144,
+			176, 9, 155, 15, 218, 187, 161, 183, 181, 29, 179, 243, 231, 206,
+			61, 32, 11, 176, 170, 95, 47, 51, 54, 215, 108, 50, 252, 22,
+			49, 101, 209, 42, 19, 182, 29, 199, 237, 232, 194, 217, 179, 13,
+			224, 121, 65, 155, 135, 145, 194, 70, 61, 104, 137, 17, 214, 131,
+			230, 236, 134, 232, 196, 89, 66, 88, 141, 55, 60, 88, 182, 27,
+			29, 24, 2, 115, 253, 6, 235, 68, 156, 121, 62, 19, 19, 128,
+			41, 27, 158, 239, 134, 187, 216, 175, 168, 196, 118, 188, 120, 155,
+			5, 33, 254, 13, 58, 49, 97, 173, 160, 225, 109, 122, 98, 113,
+			148, 152, 27, 114, 214, 230, 97, 203, 139, 99, 222, 96, 237, 48,
+			184, 233, 53, 120, 131, 197, 219, 110, 204, 226, 109, 24, 29, 72,
+			32, 158, 191, 197, 234, 129, 223, 240, 112, 19, 129, 66, 132, 181,
+			120, 124, 129, 16, 6, 255, 102, 122, 58, 22, 177, 96, 83, 245,
+			168, 30, 52, 56, 107, 117, 162, 152, 133, 60, 118, 61, 31, 107,
+			117, 55, 130, 155, 240, 73, 98, 140, 48, 63, 136, 189, 58, 47,
+			177, 120, 219, 139, 88, 211, 139, 98, 168, 33, 221, 162, 223, 232,
+			233, 78, 195, 139, 234, 77, 215, 107, 241, 176, 124, 171, 78, 120,
+			126, 26, 23, 170, 19, 237, 48, 104, 116, 234, 60, 233, 7, 73,
+			58, 242, 183, 234, 7, 97, 114, 116, 141, 160, 222, 129, 117, 235,
+			170, 73, 58, 27, 132, 44, 136, 183, 121, 200, 90, 110, 204, 67,
+			207, 109, 70, 9, 170, 113, 130, 226, 109, 78, 88, 186, 247, 122,
+			80, 75, 220, 195, 146, 80, 49, 48, 87, 232, 80, 154, 182, 252,
+			32, 249, 134, 120, 247, 226, 8, 70, 228, 139, 170, 130, 48, 98,
+			45, 119, 151, 109, 112, 160, 148, 6, 139, 3, 198, 253, 70, 16,
+			70, 28, 136, 162, 29, 6, 173, 32, 230, 76, 224, 36, 142, 88,
+			131, 135, 222, 77, 222, 96, 155, 97, 208, 34, 2, 11, 81, 176,
+			25, 239, 0, 153, 72, 10, 98, 81, 155, 215, 129, 130, 88, 59,
+			244, 128, 176, 66, 160, 29, 95, 80, 81, 20, 97, 223, 9, 91,
+			125, 164, 186, 194, 86, 150, 47, 175, 62, 54, 87, 171, 176, 234,
+			10, 187, 86, 91, 126, 101, 117, 161, 178, 192, 46, 93, 103, 171,
+			143, 84, 216, 252, 242, 181, 235, 181, 234, 149, 71, 86, 217, 35,
+			203, 139, 11, 149, 218, 10, 155, 91, 90, 96, 243, 203, 75, 171,
+			181, 234, 165, 181, 213, 229, 218, 10, 97, 197, 185, 21, 86, 93,
+			41, 226, 151, 185, 165, 235, 172, 242, 170, 107, 181, 202, 202, 10,
+			91, 174, 177, 234, 213, 107, 139, 213, 202, 2, 123, 108, 174, 86,
+			155, 91, 90, 173, 86, 86, 74, 172, 186, 52, 191, 184, 182, 80,
+			93, 186, 82, 98, 151, 214, 86, 217, 210, 242, 42, 97, 139, 213,
+			171, 213, 213, 202, 2, 91, 93, 46, 97, 179, 123, 203, 177, 229,
+			203, 236, 106, 165, 54, 255, 200, 220, 210, 234, 220, 165, 234, 98,
+			117, 245, 58, 54, 120, 185, 186, 186, 4, 141, 93, 94, 174, 17,
+			54, 199, 174, 205, 213, 86, 171, 243, 107, 139, 115, 53, 118, 109,
+			173, 118, 109, 121, 165, 194, 96, 100, 11, 213, 149, 249, 197, 185,
+			234, 213, 202, 66, 153, 85, 151, 216, 210, 50, 171, 188, 178, 178,
+			180, 202, 86, 30, 153, 91, 92, 236, 30, 40, 97, 203, 143, 45,
+			85, 106, 208, 251, 244, 48, 217, 165, 10, 91, 172, 206, 93, 90,
+			172, 64, 83, 56, 206, 133, 106, 173, 50, 191, 10, 3, 74, 126,
+			205, 87, 23, 42, 75, 171, 115, 139, 37, 194, 86, 174, 85, 230,
+			171, 115, 139, 37, 86, 121, 85, 229, 234, 181, 197, 185, 218, 245,
+			146, 172, 116, 165, 242, 29, 107, 149, 165, 213, 234, 220, 34, 91,
+			152, 187, 58, 119, 165, 178, 194, 166, 94, 12, 43, 215, 106, 203,
+			243, 107, 181, 202, 85, 232, 245, 242, 101, 182, 178, 118, 105, 101,
+			181, 186, 186, 182, 90, 97, 87, 150, 151, 23, 16, 217, 43, 149,
+			218, 43, 171, 243, 149, 149, 139, 108, 113, 121, 5, 17, 182, 182,
+			82, 41, 17, 182, 48, 183, 58, 135, 77, 95, 171, 45, 95, 174,
+			174, 174, 92, 132, 223, 151, 214, 86, 170, 136, 184, 234, 210, 106,
+			165, 86, 91, 195, 83, 151, 105, 246, 200, 242, 99, 149, 87, 86,
+			106, 108, 126, 110, 109, 165, 178, 128, 24, 94, 94, 130, 209, 2,
+			173, 84, 150, 107, 215, 161, 90, 192, 3, 206, 64, 137, 61, 246,
+			72, 101, 245, 145, 74, 13, 144, 138, 216, 154, 3, 52, 128, 74,
+			55, 191, 154, 206, 182, 92, 99, 171, 203, 181, 85, 146, 26, 39,
+			91, 170, 92, 89, 172, 94, 169, 44, 205, 87, 224, 243, 50, 84,
+			243, 88, 117, 165, 50, 205, 230, 106, 213, 21, 200, 80, 197, 134,
+			217, 99, 115, 215, 217, 242, 26, 142, 26, 38, 106, 109, 165, 66,
+			196, 239, 20, 233, 150, 112, 62, 89, 245, 50, 155, 91, 120, 101,
+			21, 122, 46, 115, 95, 91, 94, 89, 169, 74, 114, 65, 180, 205,
+			63, 34, 113, 94, 38, 231, 63, 99, 178, 185, 78, 188, 29, 132,
+			23, 216, 13, 238, 199, 129, 255, 247, 18, 198, 206, 166, 94, 129,
+			73, 236, 149, 110, 216, 112, 167, 9, 99, 151, 92, 88, 153, 129,
+			207, 130, 208, 219, 242, 124, 183, 185, 119, 3, 106, 240, 200, 219,
+			242, 217, 198, 46, 97, 108, 197, 245, 159, 116, 119, 217, 149, 109,
+			222, 114, 119, 220, 184, 196, 30, 229, 155, 155, 108, 129, 187, 192,
+			206, 253, 134, 224, 52, 17, 46, 194, 109, 206, 164, 173, 39, 18,
+			204, 201, 139, 24, 108, 219, 76, 236, 151, 27, 130, 11, 54, 248,
+			166, 231, 75, 6, 183, 25, 116, 252, 6, 228, 21, 59, 50, 230,
+			142, 202, 176, 0, 110, 186, 77, 175, 145, 78, 102, 117, 215, 7,
+			190, 18, 135, 174, 31, 53, 65, 194, 96, 13, 47, 228, 245, 184,
+			185, 11, 108, 198, 101, 251, 184, 39, 17, 205, 69, 92, 127, 87,
+			242, 68, 207, 23, 91, 40, 48, 203, 41, 94, 222, 42, 235, 60,
+			161, 16, 135, 128, 165, 49, 175, 213, 14, 194, 56, 154, 46, 19,
+			146, 35, 134, 73, 173, 233, 190, 9, 248, 149, 163, 214, 153, 190,
+			5, 210, 79, 204, 220, 128, 248, 41, 18, 75, 125, 37, 76, 52,
+			196, 79, 145, 56, 219, 119, 23, 38, 202, 159, 34, 177, 220, 119,
+			63, 38, 158, 22, 63, 69, 226, 217, 190, 19, 152, 120, 74, 252,
+			20, 137, 231, 250, 142, 99, 226, 113, 241, 83, 36, 222, 211, 119,
+			132, 124, 39, 49, 115, 253, 248, 179, 16, 179, 94, 87, 48, 177,
+			241, 108, 112, 166, 236, 219, 13, 216, 143, 128, 141, 242, 6, 219,
+			224, 117, 23, 182, 240, 80, 11, 38, 179, 27, 64, 14, 132, 185,
+			205, 173, 32, 244, 226, 237, 86, 196, 26, 129, 63, 25, 179, 157,
+			32, 188, 193, 26, 29, 16, 218, 217, 70, 16, 196, 81, 28, 186,
+			237, 182, 231, 111, 149, 9, 121, 146, 152, 118, 31, 181, 31, 232,
+			187, 96, 20, 30, 199, 121, 87, 226, 3, 171, 7, 173, 182, 215,
+			228, 33, 78, 151, 56, 114, 217, 51, 55, 43, 60, 198, 45, 195,
+			245, 124, 168, 29, 136, 66, 244, 157, 8, 2, 96, 94, 204, 218,
+			110, 24, 1, 41, 16, 66, 44, 187, 207, 160, 214, 3, 185, 67,
+			100, 128, 216, 118, 159, 217, 71, 173, 7, 205, 41, 50, 72, 28,
+			0, 108, 128, 136, 130, 50, 212, 122, 112, 224, 152, 130, 12, 106,
+			61, 120, 252, 164, 130, 44, 106, 61, 120, 231, 36, 57, 75, 76,
+			219, 160, 246, 67, 125, 175, 54, 10, 39, 217, 130, 36, 205, 136,
+			185, 216, 247, 38, 143, 121, 154, 236, 100, 15, 12, 131, 90, 15,
+			229, 14, 147, 7, 137, 109, 27, 208, 131, 151, 155, 135, 139, 37,
+			65, 152, 176, 23, 150, 88, 200, 155, 168, 42, 1, 49, 134, 65,
+			16, 167, 132, 146, 56, 228, 92, 244, 208, 192, 254, 190, 220, 212,
+			144, 67, 173, 151, 15, 140, 42, 200, 160, 214, 203, 233, 1, 5,
+			89, 212, 122, 249, 161, 2, 153, 193, 38, 13, 106, 61, 108, 30,
+			43, 30, 101, 72, 178, 197, 205, 32, 40, 150, 240, 79, 121, 195,
+			13, 139, 37, 198, 227, 122, 89, 213, 106, 216, 144, 89, 67, 14,
+			181, 30, 214, 109, 192, 64, 30, 166, 135, 20, 100, 81, 235, 225,
+			35, 71, 201, 61, 216, 134, 73, 173, 75, 230, 137, 194, 36, 91,
+			82, 187, 187, 156, 14, 92, 12, 64, 62, 187, 201, 162, 214, 173,
+			153, 54, 20, 211, 144, 67, 173, 75, 186, 53, 232, 246, 37, 122,
+			68, 65, 22, 181, 46, 29, 103, 228, 59, 176, 53, 139, 90, 11,
+			230, 84, 97, 129, 161, 231, 131, 104, 15, 72, 65, 56, 250, 37,
+			141, 202, 62, 72, 97, 71, 187, 245, 9, 121, 9, 69, 42, 221,
+			21, 203, 134, 58, 53, 228, 80, 107, 97, 32, 175, 32, 131, 90,
+			11, 163, 69, 5, 65, 235, 167, 39, 201, 211, 216, 21, 155, 90,
+			87, 204, 59, 11, 173, 222, 174, 236, 112, 247, 198, 237, 117, 164,
+			76, 216, 229, 32, 148, 162, 210, 44, 10, 236, 192, 89, 91, 222,
+			86, 40, 88, 77, 224, 55, 119, 203, 108, 33, 0, 153, 15, 100,
+			35, 221, 103, 27, 27, 215, 144, 67, 173, 43, 186, 207, 182, 65,
+			173, 43, 163, 76, 65, 22, 181, 174, 156, 60, 77, 238, 195, 62,
+			59, 212, 122, 212, 44, 21, 166, 81, 218, 143, 131, 246, 44, 218,
+			101, 186, 184, 107, 154, 7, 235, 246, 28, 27, 10, 106, 40, 67,
+			173, 71, 7, 10, 10, 50, 168, 245, 232, 225, 73, 5, 89, 212,
+			122, 116, 230, 12, 174, 58, 195, 204, 80, 235, 21, 230, 172, 252,
+			148, 177, 1, 82, 149, 100, 224, 155, 92, 117, 134, 153, 49, 168,
+			245, 138, 227, 83, 10, 178, 168, 245, 138, 51, 37, 89, 73, 150,
+			90, 139, 102, 89, 126, 202, 218, 0, 169, 74, 178, 25, 106, 45,
+			14, 156, 80, 144, 65, 173, 197, 226, 180, 130, 44, 106, 45, 150,
+			102, 101, 37, 57, 106, 93, 213, 149, 228, 108, 128, 84, 37, 185,
+			12, 181, 174, 14, 28, 87, 144, 65, 173, 171, 76, 85, 146, 179,
+			168, 117, 85, 87, 210, 79, 173, 101, 243, 164, 252, 212, 111, 3,
+			164, 42, 233, 207, 80, 107, 121, 64, 45, 195, 126, 131, 90, 203,
+			7, 213, 224, 250, 45, 106, 45, 159, 40, 146, 63, 53, 176, 22,
+			66, 173, 53, 243, 108, 225, 243, 6, 91, 21, 136, 230, 205, 134,
+			98, 109, 17, 83, 206, 54, 93, 123, 142, 187, 1, 123, 13, 144,
+			144, 222, 127, 83, 186, 75, 153, 176, 235, 65, 7, 101, 232, 200,
+			221, 228, 205, 93, 22, 242, 22, 104, 47, 56, 145, 220, 143, 189,
+			144, 203, 102, 212, 182, 181, 237, 134, 45, 96, 163, 97, 199, 143,
+			189, 22, 39, 108, 179, 227, 215, 69, 195, 94, 188, 171, 72, 57,
+			217, 38, 34, 54, 59, 139, 73, 233, 94, 121, 17, 243, 57, 111,
+			160, 96, 208, 220, 197, 157, 95, 234, 137, 160, 90, 176, 56, 8,
+			154, 145, 38, 33, 98, 195, 176, 53, 148, 161, 214, 218, 128, 226,
+			40, 196, 160, 214, 90, 97, 70, 65, 22, 181, 214, 102, 203, 228,
+			181, 136, 173, 1, 106, 93, 55, 143, 21, 174, 225, 142, 33, 60,
+			62, 245, 162, 79, 49, 92, 241, 185, 211, 150, 203, 14, 237, 67,
+			168, 5, 178, 34, 102, 59, 95, 68, 193, 67, 0, 119, 23, 117,
+			183, 6, 108, 168, 95, 67, 14, 181, 174, 107, 70, 52, 96, 80,
+			235, 58, 157, 80, 144, 69, 173, 235, 135, 143, 146, 73, 98, 218,
+			38, 181, 95, 219, 247, 6, 163, 112, 184, 107, 43, 144, 114, 12,
+			3, 125, 95, 110, 1, 192, 203, 94, 155, 59, 136, 244, 99, 194,
+			22, 240, 184, 121, 24, 235, 51, 145, 169, 63, 46, 91, 54, 145,
+			169, 63, 46, 91, 54, 145, 169, 63, 46, 153, 186, 137, 76, 253,
+			241, 67, 5, 89, 137, 65, 173, 39, 204, 25, 249, 9, 184, 246,
+			19, 186, 18, 35, 67, 173, 39, 36, 37, 155, 200, 181, 159, 96,
+			167, 21, 100, 81, 235, 137, 169, 105, 89, 137, 73, 45, 87, 46,
+			7, 19, 153, 177, 171, 43, 129, 69, 235, 234, 74, 160, 57, 87,
+			46, 7, 19, 153, 177, 43, 151, 3, 2, 117, 243, 140, 252, 4,
+			108, 180, 174, 43, 177, 50, 212, 170, 75, 22, 97, 34, 27, 173,
+			31, 190, 83, 65, 80, 110, 122, 70, 86, 98, 83, 171, 33, 89,
+			132, 137, 124, 173, 161, 43, 177, 51, 212, 106, 72, 22, 97, 34,
+			95, 107, 72, 22, 97, 34, 95, 107, 156, 41, 145, 65, 168, 196,
+			234, 163, 246, 166, 121, 195, 18, 223, 44, 192, 222, 38, 153, 32,
+			135, 73, 6, 32, 64, 251, 150, 125, 180, 56, 8, 218, 104, 179,
+			19, 121, 200, 252, 135, 73, 86, 124, 180, 225, 235, 96, 2, 59,
+			212, 218, 26, 162, 9, 108, 80, 107, 107, 108, 34, 129, 45, 106,
+			109, 29, 62, 162, 43, 55, 168, 181, 109, 31, 46, 14, 178, 202,
+			83, 123, 43, 135, 233, 217, 78, 85, 14, 219, 234, 118, 170, 114,
+			152, 162, 237, 177, 3, 9, 108, 81, 107, 251, 80, 129, 12, 201,
+			202, 77, 106, 61, 105, 159, 213, 159, 1, 89, 79, 166, 170, 131,
+			169, 122, 114, 168, 152, 192, 6, 181, 158, 60, 57, 147, 192, 22,
+			181, 158, 156, 45, 75, 76, 59, 212, 106, 234, 57, 7, 142, 222,
+			212, 152, 6, 142, 222, 148, 203, 209, 68, 142, 222, 44, 168, 57,
+			7, 142, 222, 212, 115, 158, 161, 150, 111, 158, 149, 159, 128, 163,
+			251, 186, 18, 224, 232, 190, 38, 28, 224, 232, 62, 83, 116, 10,
+			28, 221, 215, 61, 201, 82, 171, 109, 42, 114, 0, 142, 222, 214,
+			149, 0, 71, 111, 235, 158, 0, 71, 111, 23, 78, 40, 200, 162,
+			86, 251, 212, 105, 242, 81, 3, 39, 221, 160, 118, 199, 124, 202,
+			42, 188, 215, 96, 232, 103, 5, 108, 65, 153, 174, 88, 236, 110,
+			49, 225, 13, 21, 149, 89, 109, 159, 84, 100, 151, 176, 175, 42,
+			179, 3, 176, 47, 100, 146, 17, 11, 66, 166, 173, 194, 12, 93,
+			201, 244, 22, 30, 185, 45, 173, 176, 164, 42, 150, 153, 90, 238,
+			46, 26, 138, 88, 112, 147, 135, 77, 183, 45, 217, 140, 105, 193,
+			68, 119, 200, 65, 73, 53, 40, 12, 222, 188, 5, 73, 10, 113,
+			239, 166, 158, 102, 33, 240, 221, 212, 84, 35, 68, 190, 155, 154,
+			36, 133, 208, 119, 83, 147, 36, 138, 125, 59, 183, 32, 73, 33,
+			231, 237, 164, 42, 7, 146, 220, 73, 85, 14, 61, 221, 209, 36,
+			41, 164, 189, 29, 205, 124, 114, 212, 218, 53, 75, 114, 62, 96,
+			27, 221, 213, 51, 7, 219, 232, 238, 192, 132, 130, 12, 106, 237,
+			30, 154, 84, 144, 69, 173, 221, 153, 51, 228, 187, 112, 230, 96,
+			31, 125, 189, 121, 186, 208, 73, 240, 39, 118, 39, 52, 16, 149,
+			216, 206, 182, 87, 223, 222, 103, 126, 212, 244, 236, 55, 21, 160,
+			254, 109, 121, 55, 185, 47, 44, 80, 80, 88, 108, 74, 60, 161,
+			137, 192, 175, 43, 145, 198, 196, 237, 251, 245, 186, 243, 253, 14,
+			181, 94, 175, 217, 47, 108, 223, 175, 167, 138, 146, 97, 251, 126,
+			125, 241, 20, 25, 32, 192, 117, 156, 239, 236, 251, 110, 195, 64,
+			230, 14, 108, 237, 59, 115, 71, 201, 50, 177, 109, 203, 236, 163,
+			246, 63, 50, 204, 11, 133, 57, 161, 219, 128, 42, 18, 178, 40,
+			14, 66, 174, 54, 117, 212, 81, 26, 1, 143, 64, 85, 10, 121,
+			61, 216, 242, 189, 167, 57, 219, 230, 33, 47, 179, 21, 206, 181,
+			96, 58, 68, 28, 168, 208, 198, 26, 53, 152, 1, 112, 224, 152,
+			2, 13, 0, 143, 223, 173, 64, 11, 192, 251, 30, 36, 175, 134,
+			142, 57, 212, 126, 147, 97, 30, 42, 92, 101, 243, 232, 89, 22,
+			161, 102, 133, 98, 30, 103, 245, 78, 20, 7, 173, 164, 79, 126,
+			66, 236, 82, 136, 245, 162, 132, 196, 211, 253, 2, 102, 107, 57,
+			125, 80, 249, 208, 132, 104, 216, 129, 126, 188, 201, 24, 26, 85,
+			160, 9, 224, 29, 19, 228, 110, 2, 220, 60, 243, 61, 70, 223,
+			151, 12, 163, 112, 186, 107, 163, 76, 100, 17, 207, 79, 246, 205,
+			50, 33, 3, 196, 178, 65, 207, 250, 30, 35, 119, 132, 12, 19,
+			219, 182, 237, 62, 154, 121, 139, 97, 62, 107, 88, 216, 128, 13,
+			106, 157, 253, 22, 35, 59, 64, 86, 72, 198, 22, 154, 157, 253,
+			54, 195, 30, 47, 204, 179, 115, 32, 145, 232, 217, 6, 5, 150,
+			135, 97, 16, 70, 101, 194, 150, 195, 6, 168, 241, 17, 219, 225,
+			94, 40, 190, 109, 123, 48, 57, 94, 221, 109, 130, 18, 31, 5,
+			62, 200, 42, 35, 36, 43, 42, 53, 176, 214, 145, 36, 193, 132,
+			4, 58, 70, 134, 101, 179, 6, 181, 191, 215, 176, 199, 116, 6,
+			67, 36, 12, 39, 9, 38, 36, 140, 82, 178, 35, 75, 152, 212,
+			126, 135, 97, 143, 21, 182, 216, 82, 16, 179, 87, 123, 91, 175,
+			118, 183, 24, 247, 65, 130, 107, 148, 25, 91, 146, 199, 102, 154,
+			65, 197, 238, 13, 206, 238, 58, 199, 54, 118, 99, 30, 149, 25,
+			91, 139, 56, 75, 57, 16, 51, 111, 147, 48, 117, 214, 150, 22,
+			120, 154, 222, 13, 222, 220, 77, 13, 6, 250, 250, 142, 116, 215,
+			68, 87, 70, 169, 30, 140, 69, 237, 127, 102, 216, 227, 58, 3,
+			240, 214, 127, 150, 30, 190, 101, 66, 2, 29, 211, 131, 177, 169,
+			253, 174, 111, 219, 96, 238, 62, 127, 251, 131, 1, 242, 120, 87,
+			122, 48, 32, 140, 189, 43, 61, 24, 135, 218, 239, 54, 236, 59,
+			116, 6, 199, 192, 132, 124, 146, 96, 66, 194, 216, 184, 46, 145,
+			161, 246, 247, 167, 75, 100, 12, 76, 72, 74, 100, 76, 72, 72,
+			149, 200, 82, 251, 61, 134, 77, 117, 134, 172, 129, 9, 67, 73,
+			130, 9, 9, 249, 81, 93, 34, 71, 237, 31, 72, 163, 56, 103,
+			96, 66, 130, 226, 156, 9, 9, 116, 140, 124, 214, 144, 69, 250,
+			169, 253, 1, 160, 236, 255, 100, 176, 85, 119, 107, 182, 193, 155,
+			94, 203, 3, 233, 86, 31, 120, 150, 9, 187, 18, 6, 157, 54,
+			138, 156, 64, 222, 201, 9, 60, 138, 187, 192, 63, 19, 161, 216,
+			243, 133, 200, 124, 119, 153, 61, 18, 236, 240, 155, 60, 44, 9,
+			51, 222, 221, 4, 20, 214, 38, 215, 39, 2, 17, 139, 182, 131,
+			78, 179, 193, 162, 216, 107, 54, 129, 137, 186, 27, 77, 180, 82,
+			32, 95, 67, 246, 187, 133, 13, 239, 160, 110, 129, 42, 1, 180,
+			72, 88, 28, 114, 55, 150, 31, 37, 191, 118, 35, 214, 241, 111,
+			248, 193, 142, 47, 83, 82, 211, 217, 111, 224, 32, 147, 233, 236,
+			55, 33, 97, 116, 140, 204, 74, 44, 16, 106, 127, 208, 176, 15,
+			20, 143, 178, 69, 238, 111, 197, 219, 251, 227, 65, 151, 39, 6,
+			230, 79, 230, 142, 152, 144, 48, 118, 7, 57, 41, 43, 28, 160,
+			246, 135, 1, 173, 99, 108, 137, 239, 0, 82, 110, 242, 16, 119,
+			250, 243, 169, 106, 6, 12, 204, 149, 244, 107, 192, 132, 132, 209,
+			132, 1, 12, 82, 251, 7, 211, 68, 51, 104, 96, 66, 50, 161,
+			131, 38, 36, 208, 132, 104, 134, 168, 253, 67, 105, 150, 49, 100,
+			96, 66, 66, 52, 67, 38, 36, 228, 19, 82, 30, 166, 246, 71,
+			12, 251, 160, 206, 48, 108, 96, 194, 104, 146, 96, 66, 194, 248,
+			1, 93, 98, 132, 218, 255, 50, 93, 98, 196, 192, 132, 164, 196,
+			136, 9, 9, 227, 7, 200, 164, 44, 145, 167, 246, 15, 27, 246,
+			29, 197, 131, 176, 38, 163, 174, 165, 44, 12, 119, 170, 100, 222,
+			192, 156, 201, 0, 243, 38, 36, 208, 113, 93, 213, 40, 181, 127,
+			228, 182, 170, 26, 53, 48, 103, 82, 213, 168, 9, 9, 136, 43,
+			96, 250, 6, 205, 252, 168, 97, 254, 107, 205, 244, 129, 185, 254,
+			168, 145, 29, 36, 51, 216, 18, 200, 79, 246, 255, 106, 216, 7,
+			11, 133, 91, 50, 125, 213, 24, 138, 75, 144, 153, 38, 9, 38,
+			36, 220, 161, 144, 6, 2, 147, 253, 175, 18, 164, 161, 12, 4,
+			9, 73, 9, 224, 229, 255, 42, 93, 194, 164, 246, 115, 233, 18,
+			80, 197, 115, 233, 18, 34, 199, 29, 7, 112, 207, 180, 161, 191,
+			31, 55, 204, 195, 98, 56, 184, 179, 127, 92, 237, 236, 54, 200,
+			119, 246, 199, 141, 129, 81, 5, 26, 0, 210, 3, 10, 180, 0,
+			60, 84, 144, 53, 25, 212, 254, 113, 195, 60, 34, 63, 26, 54,
+			130, 170, 38, 195, 1, 112, 32, 175, 64, 204, 60, 122, 80, 129,
+			22, 128, 133, 195, 178, 38, 147, 218, 63, 145, 244, 9, 56, 250,
+			79, 36, 53, 1, 79, 252, 137, 164, 38, 104, 246, 39, 140, 81,
+			213, 39, 216, 48, 126, 2, 250, 244, 62, 3, 171, 178, 168, 253,
+			211, 32, 111, 188, 205, 96, 213, 77, 166, 239, 66, 193, 212, 68,
+			60, 150, 103, 149, 62, 231, 13, 37, 209, 69, 60, 46, 51, 200,
+			187, 17, 224, 209, 162, 39, 207, 45, 85, 73, 130, 204, 63, 41,
+			171, 237, 211, 62, 138, 249, 250, 54, 78, 137, 165, 239, 242, 128,
+			224, 158, 220, 245, 41, 171, 177, 88, 54, 118, 79, 131, 25, 0,
+			7, 70, 20, 104, 0, 152, 31, 87, 32, 142, 229, 224, 4, 249,
+			41, 19, 135, 102, 83, 251, 147, 134, 201, 10, 63, 98, 162, 177,
+			78, 233, 248, 208, 91, 238, 119, 90, 216, 229, 72, 246, 210, 139,
+			186, 206, 64, 225, 55, 218, 2, 112, 164, 234, 11, 97, 120, 255,
+			35, 18, 167, 170, 46, 155, 44, 79, 150, 64, 48, 244, 34, 182,
+			217, 105, 54, 119, 103, 95, 215, 113, 155, 222, 166, 135, 251, 232,
+			114, 188, 205, 195, 29, 47, 226, 37, 54, 127, 230, 204, 44, 236,
+			134, 44, 170, 7, 109, 207, 223, 34, 44, 236, 52, 229, 46, 169,
+			206, 77, 55, 61, 121, 248, 139, 251, 193, 148, 87, 230, 101, 182,
+			233, 133, 145, 176, 29, 137, 107, 164, 162, 199, 74, 250, 130, 126,
+			147, 100, 84, 136, 116, 55, 172, 111, 243, 6, 140, 137, 251, 73,
+			62, 148, 105, 185, 31, 151, 88, 224, 51, 216, 114, 2, 76, 12,
+			131, 32, 38, 76, 187, 54, 79, 107, 172, 219, 2, 115, 26, 116,
+			0, 212, 68, 14, 219, 248, 39, 13, 170, 168, 207, 182, 0, 60,
+			118, 156, 252, 67, 68, 186, 67, 237, 95, 52, 204, 227, 133, 54,
+			226, 60, 17, 79, 95, 24, 207, 108, 131, 123, 254, 22, 147, 183,
+			235, 0, 125, 85, 64, 43, 1, 246, 16, 52, 111, 138, 29, 48,
+			209, 28, 92, 223, 231, 33, 108, 78, 154, 234, 116, 223, 29, 27,
+			59, 160, 65, 236, 143, 238, 59, 8, 20, 191, 104, 208, 130, 2,
+			45, 0, 143, 30, 35, 63, 38, 40, 38, 67, 237, 95, 53, 204,
+			83, 133, 15, 11, 138, 241, 59, 45, 30, 122, 117, 69, 40, 218,
+			208, 215, 101, 205, 139, 249, 83, 226, 224, 159, 71, 250, 84, 94,
+			142, 12, 133, 33, 105, 42, 222, 8, 130, 38, 119, 1, 15, 197,
+			56, 236, 240, 34, 16, 124, 17, 157, 239, 138, 50, 135, 240, 152,
+			234, 109, 71, 222, 39, 20, 205, 192, 23, 212, 9, 166, 96, 49,
+			242, 168, 238, 182, 5, 106, 92, 127, 151, 237, 184, 187, 211, 170,
+			49, 16, 213, 122, 42, 154, 215, 249, 69, 183, 132, 223, 8, 230,
+			100, 47, 127, 136, 221, 117, 254, 1, 164, 33, 153, 169, 76, 216,
+			234, 242, 194, 242, 148, 56, 96, 156, 190, 32, 206, 17, 103, 239,
+			187, 71, 74, 138, 15, 43, 4, 103, 108, 196, 153, 6, 29, 0,
+			53, 190, 65, 28, 251, 85, 131, 30, 87, 160, 5, 96, 241, 36,
+			249, 199, 130, 249, 100, 169, 253, 105, 195, 60, 81, 184, 9, 171,
+			12, 89, 6, 40, 130, 145, 52, 87, 54, 56, 218, 11, 93, 134,
+			23, 38, 21, 5, 164, 79, 146, 118, 219, 124, 50, 98, 201, 197,
+			101, 34, 140, 243, 44, 109, 158, 245, 132, 125, 15, 100, 89, 49,
+			47, 110, 44, 74, 104, 146, 201, 218, 216, 13, 13, 58, 0, 106,
+			254, 9, 242, 225, 167, 141, 81, 197, 182, 179, 22, 128, 199, 25,
+			249, 154, 24, 66, 142, 218, 191, 1, 67, 248, 162, 193, 30, 93,
+			89, 94, 74, 81, 182, 234, 65, 25, 181, 74, 68, 186, 228, 170,
+			160, 16, 239, 57, 65, 43, 75, 78, 67, 128, 37, 132, 108, 219,
+			21, 89, 93, 86, 212, 55, 68, 139, 82, 243, 131, 101, 156, 212,
+			95, 146, 99, 194, 79, 147, 145, 104, 137, 176, 29, 41, 254, 1,
+			131, 41, 167, 185, 145, 23, 79, 130, 180, 217, 232, 212, 165, 43,
+			134, 240, 57, 129, 170, 38, 35, 209, 255, 141, 93, 64, 243, 77,
+			30, 198, 200, 173, 188, 24, 88, 70, 221, 109, 241, 230, 188, 27,
+			37, 107, 45, 103, 227, 224, 53, 232, 0, 168, 231, 30, 196, 228,
+			223, 72, 248, 68, 206, 2, 240, 24, 147, 91, 88, 63, 181, 127,
+			211, 48, 79, 201, 143, 253, 54, 130, 170, 166, 254, 12, 128, 3,
+			106, 247, 3, 73, 243, 55, 141, 9, 69, 69, 253, 22, 128, 197,
+			147, 228, 199, 251, 177, 42, 66, 237, 63, 54, 204, 211, 133, 31,
+			234, 71, 20, 134, 29, 158, 48, 27, 87, 202, 205, 172, 168, 172,
+			243, 197, 50, 123, 12, 184, 163, 254, 162, 201, 68, 229, 0, 20,
+			129, 52, 236, 214, 111, 68, 76, 172, 235, 58, 103, 32, 174, 134,
+			141, 38, 143, 228, 73, 25, 20, 146, 230, 98, 81, 97, 207, 149,
+			91, 100, 117, 73, 95, 68, 1, 181, 25, 110, 240, 102, 0, 4,
+			28, 104, 234, 142, 3, 194, 34, 111, 11, 57, 74, 192, 130, 102,
+			67, 117, 175, 46, 205, 0, 56, 201, 186, 55, 88, 57, 122, 246,
+			163, 224, 212, 77, 109, 192, 56, 229, 154, 137, 152, 144, 222, 93,
+			64, 69, 49, 218, 245, 227, 109, 30, 123, 245, 162, 248, 94, 146,
+			46, 71, 123, 250, 231, 197, 17, 139, 130, 38, 58, 98, 225, 202,
+			153, 226, 110, 125, 91, 117, 73, 15, 81, 20, 218, 226, 113, 132,
+			37, 160, 33, 221, 132, 104, 97, 186, 204, 86, 84, 138, 236, 84,
+			196, 248, 83, 94, 20, 39, 39, 107, 234, 164, 2, 205, 62, 162,
+			75, 13, 113, 112, 166, 174, 157, 33, 127, 155, 187, 86, 221, 175,
+			50, 45, 95, 132, 13, 30, 130, 102, 177, 25, 195, 222, 208, 108,
+			178, 98, 200, 221, 166, 28, 41, 122, 44, 164, 165, 0, 161, 206,
+			148, 246, 204, 154, 50, 242, 212, 65, 25, 18, 13, 71, 188, 229,
+			250, 48, 34, 225, 147, 87, 130, 137, 130, 57, 240, 3, 127, 54,
+			228, 109, 142, 58, 91, 119, 189, 204, 109, 238, 184, 187, 114, 142,
+			244, 172, 105, 229, 13, 86, 20, 234, 103, 4, 216, 156, 7, 90,
+			159, 96, 122, 186, 169, 6, 143, 93, 175, 9, 148, 182, 179, 205,
+			181, 139, 22, 114, 134, 157, 48, 136, 121, 138, 158, 97, 39, 241,
+			131, 24, 15, 82, 188, 72, 249, 77, 116, 34, 190, 217, 105, 34,
+			113, 132, 65, 199, 111, 204, 198, 161, 135, 231, 249, 169, 243, 119,
+			113, 0, 131, 104, 169, 7, 126, 228, 69, 232, 36, 205, 118, 56,
+			65, 62, 188, 103, 76, 189, 147, 203, 220, 102, 20, 148, 24, 191,
+			201, 97, 42, 131, 206, 214, 182, 148, 134, 96, 238, 66, 254, 186,
+			142, 23, 114, 208, 43, 131, 61, 120, 88, 149, 203, 147, 163, 187,
+			151, 219, 108, 238, 202, 115, 87, 215, 143, 181, 215, 66, 156, 24,
+			227, 234, 174, 63, 9, 107, 146, 55, 155, 204, 219, 212, 118, 40,
+			47, 125, 118, 19, 132, 204, 245, 81, 178, 43, 177, 40, 128, 158,
+			32, 105, 200, 153, 80, 243, 73, 122, 7, 1, 132, 129, 170, 244,
+			249, 30, 186, 142, 20, 21, 2, 27, 22, 107, 164, 233, 110, 149,
+			210, 221, 219, 101, 110, 51, 228, 110, 99, 87, 79, 35, 73, 42,
+			65, 33, 241, 137, 238, 219, 225, 79, 104, 222, 73, 108, 228, 90,
+			26, 116, 0, 212, 146, 45, 168, 194, 127, 108, 228, 21, 199, 35,
+			22, 128, 197, 83, 164, 72, 64, 26, 203, 252, 137, 209, 247, 167,
+			134, 81, 24, 239, 50, 205, 169, 209, 12, 16, 203, 6, 57, 231,
+			79, 140, 220, 17, 100, 182, 14, 232, 48, 95, 81, 250, 130, 131,
+			58, 204, 87, 84, 211, 14, 234, 48, 95, 81, 108, 219, 65, 29,
+			230, 43, 74, 135, 113, 80, 135, 249, 138, 210, 97, 28, 80, 38,
+			158, 87, 108, 219, 65, 29, 230, 249, 164, 38, 35, 3, 160, 100,
+			219, 14, 234, 48, 207, 43, 182, 237, 160, 14, 243, 60, 176, 237,
+			73, 98, 218, 25, 154, 249, 11, 163, 239, 29, 166, 81, 56, 148,
+			30, 132, 159, 72, 230, 114, 36, 32, 65, 252, 133, 145, 19, 154,
+			79, 6, 70, 242, 85, 53, 146, 12, 142, 228, 171, 170, 253, 12,
+			142, 228, 171, 106, 36, 25, 28, 201, 87, 213, 72, 50, 56, 146,
+			175, 170, 145, 100, 96, 36, 95, 51, 204, 178, 252, 8, 35, 249,
+			90, 82, 19, 140, 228, 107, 198, 192, 73, 5, 98, 230, 83, 211,
+			10, 180, 0, 44, 205, 202, 154, 76, 106, 127, 221, 48, 85, 94,
+			80, 59, 190, 158, 212, 4, 50, 229, 215, 141, 1, 213, 9, 104,
+			246, 235, 198, 193, 99, 10, 180, 0, 60, 81, 36, 207, 129, 0,
+			154, 177, 250, 104, 230, 31, 155, 230, 155, 77, 171, 240, 1, 115,
+			159, 163, 17, 37, 144, 10, 43, 91, 234, 16, 67, 154, 221, 246,
+			59, 24, 225, 126, 28, 122, 61, 167, 32, 128, 228, 125, 143, 64,
+			122, 78, 64, 216, 18, 176, 26, 233, 174, 43, 86, 108, 195, 139,
+			98, 207, 175, 199, 66, 114, 120, 193, 88, 44, 162, 73, 55, 198,
+			237, 20, 74, 171, 3, 19, 22, 117, 234, 219, 234, 19, 50, 44,
+			183, 221, 14, 131, 118, 232, 185, 177, 56, 3, 151, 210, 52, 246,
+			88, 158, 130, 123, 126, 124, 247, 121, 194, 26, 65, 203, 245, 124,
+			185, 140, 50, 22, 204, 241, 63, 54, 201, 97, 114, 132, 100, 0,
+			4, 2, 121, 147, 185, 231, 124, 6, 116, 251, 140, 56, 50, 132,
+			207, 131, 73, 130, 3, 9, 67, 52, 73, 48, 32, 97, 108, 34,
+			73, 176, 32, 225, 240, 17, 221, 130, 65, 237, 239, 54, 241, 144,
+			102, 191, 22, 128, 144, 190, 59, 221, 2, 40, 246, 223, 157, 110,
+			193, 192, 10, 198, 14, 36, 9, 22, 36, 28, 42, 136, 211, 177,
+			12, 208, 196, 91, 77, 243, 220, 254, 167, 99, 183, 36, 129, 238,
+			15, 189, 164, 64, 64, 166, 195, 181, 37, 191, 247, 146, 3, 3,
+			249, 217, 21, 222, 43, 183, 36, 13, 146, 162, 13, 65, 192, 160,
+			160, 191, 213, 212, 212, 14, 10, 250, 91, 205, 129, 35, 10, 52,
+			0, 60, 122, 70, 129, 56, 178, 242, 89, 210, 196, 113, 218, 212,
+			126, 187, 105, 158, 42, 60, 158, 52, 151, 244, 240, 150, 39, 74,
+			33, 23, 18, 237, 190, 135, 70, 100, 191, 83, 35, 209, 184, 45,
+			154, 211, 160, 3, 160, 230, 21, 160, 212, 190, 221, 148, 138, 74,
+			6, 149, 218, 183, 155, 197, 147, 228, 46, 98, 218, 89, 154, 249,
+			62, 179, 239, 7, 204, 94, 255, 49, 209, 79, 117, 22, 34, 88,
+			151, 228, 90, 160, 52, 124, 159, 153, 19, 194, 110, 22, 136, 242,
+			93, 166, 228, 90, 89, 36, 194, 119, 169, 158, 100, 145, 4, 223,
+			165, 122, 146, 69, 2, 124, 151, 41, 185, 86, 22, 201, 239, 93,
+			166, 228, 90, 89, 32, 190, 119, 155, 210, 134, 148, 69, 98, 123,
+			119, 82, 19, 144, 218, 187, 77, 169, 185, 100, 145, 208, 222, 109,
+			74, 27, 82, 22, 201, 236, 221, 166, 180, 33, 101, 129, 107, 189,
+			199, 52, 167, 228, 71, 152, 140, 247, 36, 53, 1, 215, 122, 143,
+			57, 160, 122, 12, 205, 190, 199, 60, 114, 82, 129, 22, 128, 119,
+			78, 146, 83, 196, 180, 115, 52, 243, 62, 179, 239, 95, 152, 70,
+			225, 64, 23, 118, 228, 101, 116, 137, 16, 80, 6, 222, 103, 230,
+			142, 97, 227, 57, 64, 200, 251, 21, 66, 114, 136, 144, 247, 171,
+			198, 115, 136, 144, 247, 43, 132, 228, 16, 33, 239, 87, 8, 201,
+			33, 66, 222, 175, 16, 146, 131, 158, 125, 192, 52, 75, 242, 35,
+			32, 228, 3, 73, 77, 192, 198, 63, 96, 14, 48, 5, 98, 230,
+			19, 147, 10, 180, 0, 156, 57, 35, 107, 50, 169, 253, 33, 211,
+			188, 83, 126, 4, 132, 124, 40, 169, 9, 16, 242, 33, 115, 224,
+			144, 2, 13, 0, 11, 39, 20, 104, 1, 120, 234, 52, 57, 71,
+			76, 187, 159, 102, 126, 200, 236, 251, 184, 105, 20, 138, 61, 62,
+			38, 241, 118, 208, 16, 26, 109, 55, 114, 64, 191, 249, 33, 51,
+			119, 20, 59, 210, 15, 200, 249, 136, 66, 78, 63, 34, 231, 35,
+			170, 35, 253, 136, 156, 143, 40, 228, 244, 35, 114, 62, 162, 144,
+			211, 143, 200, 249, 8, 32, 231, 251, 13, 172, 202, 160, 246, 179,
+			166, 121, 162, 240, 191, 24, 172, 234, 163, 111, 166, 223, 80, 110,
+			154, 104, 119, 193, 85, 134, 10, 51, 143, 132, 17, 105, 95, 139,
+			203, 142, 187, 203, 220, 136, 176, 125, 195, 41, 105, 35, 76, 137,
+			109, 116, 98, 117, 19, 98, 19, 100, 214, 96, 175, 111, 141, 232,
+			40, 76, 212, 179, 201, 168, 128, 114, 159, 77, 70, 101, 96, 191,
+			233, 17, 5, 90, 0, 30, 103, 18, 63, 38, 181, 63, 106, 154,
+			69, 249, 17, 38, 234, 163, 73, 77, 166, 3, 160, 174, 9, 48,
+			240, 81, 147, 30, 85, 160, 5, 32, 59, 33, 107, 178, 168, 253,
+			49, 211, 60, 45, 63, 2, 47, 251, 88, 82, 19, 240, 178, 143,
+			153, 3, 19, 10, 52, 0, 60, 196, 20, 136, 101, 79, 158, 34,
+			21, 172, 201, 166, 246, 115, 166, 121, 127, 225, 126, 86, 85, 215,
+			243, 34, 144, 84, 133, 246, 198, 68, 44, 16, 80, 86, 68, 116,
+			13, 149, 174, 252, 167, 85, 155, 182, 168, 71, 131, 14, 128, 82,
+			42, 236, 71, 38, 245, 156, 153, 87, 93, 0, 38, 245, 28, 116,
+			65, 130, 57, 0, 79, 223, 167, 192, 44, 128, 231, 238, 149, 29,
+			116, 168, 253, 99, 251, 117, 80, 68, 35, 217, 219, 65, 153, 222,
+			219, 65, 199, 198, 122, 52, 136, 213, 234, 14, 130, 216, 249, 99,
+			73, 7, 29, 11, 64, 221, 65, 39, 7, 160, 238, 160, 147, 5,
+			240, 220, 189, 228, 185, 97, 98, 218, 132, 102, 254, 179, 217, 247,
+			31, 45, 227, 252, 18, 123, 232, 111, 255, 143, 48, 121, 39, 149,
+			156, 255, 143, 67, 172, 2, 202, 171, 246, 140, 75, 60, 38, 197,
+			77, 33, 216, 60, 182, 221, 155, 90, 167, 138, 138, 204, 141, 197,
+			29, 185, 244, 202, 32, 236, 73, 212, 39, 244, 45, 182, 40, 181,
+			57, 9, 149, 0, 175, 35, 197, 1, 108, 66, 74, 117, 109, 176,
+			168, 233, 109, 109, 199, 205, 93, 214, 240, 54, 55, 121, 200, 253,
+			24, 246, 41, 80, 69, 221, 93, 101, 205, 98, 219, 30, 232, 248,
+			155, 168, 137, 53, 164, 200, 213, 114, 125, 175, 221, 105, 162, 114,
+			168, 205, 69, 106, 66, 64, 58, 83, 30, 2, 80, 209, 254, 30,
+			2, 110, 180, 199, 67, 128, 179, 25, 137, 154, 84, 93, 114, 148,
+			169, 188, 106, 199, 221, 229, 184, 235, 74, 43, 130, 112, 212, 70,
+			45, 210, 131, 245, 30, 5, 61, 90, 26, 10, 9, 232, 58, 145,
+			88, 64, 149, 176, 209, 42, 51, 86, 245, 163, 152, 187, 13, 161,
+			0, 163, 131, 5, 124, 64, 19, 166, 210, 233, 252, 174, 78, 38,
+			22, 238, 186, 219, 108, 242, 6, 219, 239, 162, 112, 57, 109, 240,
+			67, 254, 131, 243, 169, 217, 151, 60, 151, 168, 135, 65, 20, 161,
+			105, 96, 47, 10, 216, 99, 92, 88, 205, 133, 106, 167, 107, 139,
+			3, 214, 14, 196, 44, 8, 235, 92, 10, 71, 59, 104, 101, 231,
+			108, 163, 227, 53, 27, 204, 77, 153, 50, 74, 128, 42, 65, 29,
+			237, 192, 243, 99, 108, 20, 231, 48, 18, 93, 219, 224, 220, 39,
+			2, 111, 226, 188, 55, 10, 48, 79, 170, 118, 96, 198, 136, 118,
+			152, 106, 125, 175, 85, 31, 203, 35, 173, 116, 207, 182, 188, 138,
+			85, 223, 14, 34, 142, 38, 31, 113, 145, 44, 186, 64, 216, 12,
+			106, 249, 42, 163, 232, 25, 90, 8, 149, 171, 13, 234, 11, 218,
+			175, 3, 166, 184, 201, 65, 44, 111, 202, 203, 124, 44, 8, 9,
+			99, 44, 8, 183, 92, 223, 123, 90, 222, 239, 11, 66, 113, 108,
+			247, 84, 155, 135, 30, 30, 65, 55, 85, 27, 37, 68, 164, 116,
+			14, 146, 93, 190, 247, 220, 185, 115, 231, 160, 150, 120, 59, 68,
+			107, 193, 131, 240, 79, 25, 251, 229, 201, 196, 110, 208, 17, 247,
+			200, 162, 78, 40, 23, 2, 36, 53, 180, 43, 181, 152, 6, 38,
+			103, 22, 171, 198, 94, 104, 214, 37, 123, 80, 126, 129, 81, 111,
+			72, 191, 243, 104, 91, 162, 31, 135, 47, 92, 209, 209, 31, 86,
+			215, 6, 45, 121, 190, 114, 255, 142, 81, 27, 137, 61, 144, 76,
+			249, 108, 203, 245, 228, 172, 110, 116, 54, 103, 183, 154, 193, 134,
+			219, 156, 213, 51, 56, 27, 242, 45, 47, 138, 195, 221, 212, 245,
+			28, 28, 124, 160, 36, 212, 148, 171, 154, 118, 120, 91, 241, 90,
+			237, 230, 174, 186, 192, 7, 67, 199, 139, 116, 79, 242, 122, 44,
+			68, 93, 188, 196, 2, 245, 44, 111, 64, 162, 119, 147, 207, 206,
+			179, 118, 179, 179, 229, 249, 211, 56, 148, 174, 34, 59, 124, 35,
+			242, 98, 206, 166, 188, 77, 230, 222, 116, 189, 166, 187, 209, 228,
+			211, 210, 37, 56, 228, 147, 17, 243, 3, 168, 12, 79, 248, 0,
+			237, 79, 181, 155, 200, 143, 130, 29, 68, 59, 172, 53, 31, 191,
+			72, 204, 183, 202, 108, 45, 234, 160, 197, 6, 190, 35, 249, 96,
+			225, 192, 71, 92, 245, 14, 169, 140, 14, 206, 194, 135, 9, 20,
+			12, 190, 103, 158, 228, 37, 82, 168, 40, 240, 83, 56, 193, 110,
+			137, 233, 221, 216, 101, 237, 78, 28, 75, 235, 149, 100, 23, 81,
+			103, 99, 182, 203, 215, 9, 79, 57, 196, 138, 80, 203, 59, 18,
+			183, 94, 88, 176, 41, 200, 14, 175, 79, 70, 146, 104, 221, 86,
+			187, 201, 97, 113, 176, 111, 246, 178, 44, 84, 34, 82, 79, 6,
+			138, 77, 201, 147, 66, 47, 98, 113, 39, 4, 102, 219, 137, 229,
+			86, 32, 248, 7, 240, 4, 152, 14, 37, 251, 105, 66, 140, 120,
+			204, 58, 109, 73, 25, 110, 39, 14, 90, 110, 236, 213, 17, 195,
+			110, 132, 23, 179, 164, 213, 95, 209, 136, 16, 26, 137, 65, 237,
+			255, 108, 230, 198, 132, 111, 58, 1, 169, 241, 51, 166, 121, 178,
+			240, 57, 131, 173, 240, 88, 152, 19, 31, 117, 111, 186, 76, 70,
+			35, 2, 110, 21, 130, 224, 225, 70, 17, 143, 82, 251, 147, 60,
+			2, 240, 34, 101, 21, 148, 29, 35, 172, 221, 116, 235, 184, 7,
+			94, 218, 85, 231, 80, 165, 148, 251, 182, 170, 24, 150, 110, 196,
+			27, 66, 250, 83, 118, 130, 96, 51, 6, 14, 231, 249, 41, 213,
+			94, 155, 206, 186, 202, 107, 59, 155, 31, 132, 45, 28, 54, 158,
+			178, 10, 178, 216, 112, 235, 55, 118, 220, 176, 17, 41, 205, 95,
+			74, 172, 66, 0, 33, 40, 29, 127, 70, 201, 35, 4, 165, 227,
+			207, 40, 233, 143, 160, 116, 252, 25, 147, 30, 83, 160, 5, 224,
+			137, 34, 249, 19, 19, 81, 102, 80, 251, 247, 77, 243, 76, 225,
+			179, 38, 155, 15, 252, 56, 12, 154, 123, 79, 39, 119, 66, 183,
+			221, 230, 161, 64, 37, 34, 47, 141, 58, 121, 61, 182, 220, 227,
+			207, 238, 198, 50, 43, 162, 82, 89, 32, 229, 86, 223, 83, 96,
+			18, 42, 140, 19, 153, 122, 106, 90, 105, 10, 32, 112, 239, 112,
+			168, 32, 66, 59, 116, 114, 235, 35, 181, 75, 136, 125, 95, 111,
+			155, 221, 61, 169, 110, 178, 125, 162, 190, 73, 51, 14, 112, 2,
+			117, 84, 12, 91, 15, 158, 106, 162, 169, 89, 209, 136, 58, 28,
+			34, 93, 23, 243, 20, 221, 202, 51, 105, 207, 143, 188, 134, 220,
+			107, 197, 174, 161, 80, 134, 209, 233, 68, 101, 122, 190, 64, 238,
+			255, 253, 100, 190, 64, 238, 255, 253, 100, 190, 12, 156, 17, 122,
+			167, 2, 45, 0, 167, 103, 200, 191, 180, 112, 190, 76, 106, 127,
+			209, 52, 47, 22, 190, 223, 130, 145, 137, 176, 100, 106, 4, 154,
+			220, 81, 126, 146, 83, 132, 55, 211, 155, 205, 212, 57, 2, 139,
+			120, 219, 197, 159, 101, 192, 140, 184, 129, 38, 248, 1, 200, 136,
+			9, 138, 37, 103, 41, 73, 131, 50, 110, 209, 114, 225, 238, 139,
+			114, 34, 141, 233, 108, 117, 187, 131, 135, 221, 32, 79, 169, 243,
+			122, 144, 56, 252, 32, 158, 217, 31, 109, 10, 95, 136, 41, 113,
+			32, 143, 62, 179, 251, 133, 248, 43, 179, 196, 1, 108, 79, 89,
+			209, 146, 114, 252, 34, 41, 66, 141, 131, 46, 242, 123, 49, 186,
+			75, 147, 29, 185, 45, 186, 147, 119, 142, 196, 188, 129, 54, 244,
+			197, 100, 146, 65, 247, 248, 162, 82, 18, 8, 170, 100, 95, 52,
+			243, 39, 21, 104, 1, 120, 231, 148, 2, 115, 0, 78, 95, 80,
+			96, 22, 192, 123, 30, 36, 211, 72, 1, 22, 181, 191, 100, 154,
+			149, 194, 97, 33, 240, 73, 198, 216, 8, 56, 158, 57, 108, 11,
+			87, 37, 81, 18, 180, 185, 47, 37, 157, 176, 28, 0, 117, 39,
+			64, 155, 251, 146, 153, 159, 85, 32, 86, 124, 238, 188, 2, 115,
+			0, 222, 189, 128, 78, 67, 0, 226, 231, 123, 230, 201, 231, 5,
+			227, 176, 169, 253, 231, 166, 249, 242, 194, 255, 101, 202, 131, 107,
+			125, 210, 151, 162, 196, 243, 47, 76, 138, 90, 206, 39, 40, 14,
+			237, 8, 235, 247, 83, 117, 46, 198, 4, 178, 37, 30, 21, 185,
+			32, 118, 199, 188, 213, 70, 25, 169, 229, 10, 13, 67, 238, 12,
+			46, 30, 66, 173, 173, 94, 158, 125, 128, 224, 89, 62, 139, 248,
+			235, 58, 120, 66, 136, 186, 183, 240, 41, 144, 39, 131, 132, 201,
+			144, 110, 169, 251, 160, 162, 83, 141, 64, 203, 202, 101, 146, 16,
+			152, 155, 242, 47, 150, 162, 156, 32, 46, 183, 14, 221, 140, 146,
+			198, 187, 219, 142, 212, 89, 148, 156, 159, 109, 23, 178, 50, 190,
+			185, 9, 34, 137, 28, 92, 34, 113, 98, 219, 77, 144, 82, 228,
+			157, 38, 61, 135, 182, 64, 180, 6, 29, 0, 245, 28, 130, 58,
+			252, 231, 102, 94, 113, 11, 80, 135, 255, 220, 156, 62, 163, 192,
+			28, 128, 165, 135, 20, 152, 5, 240, 254, 151, 145, 69, 152, 65,
+			187, 143, 102, 190, 102, 154, 127, 99, 90, 133, 151, 49, 29, 195,
+			68, 51, 62, 121, 134, 182, 223, 149, 90, 165, 161, 69, 222, 211,
+			186, 159, 232, 217, 252, 53, 51, 59, 70, 22, 128, 94, 132, 103,
+			243, 95, 153, 246, 112, 241, 30, 93, 121, 114, 219, 20, 139, 67,
+			133, 82, 149, 42, 1, 99, 241, 220, 166, 146, 170, 133, 45, 153,
+			72, 87, 230, 191, 50, 237, 254, 36, 193, 132, 132, 193, 33, 114,
+			69, 182, 99, 80, 251, 27, 166, 77, 11, 25, 113, 33, 180, 120,
+			22, 189, 115, 147, 56, 36, 203, 237, 8, 8, 65, 187, 136, 74,
+			190, 36, 22, 186, 114, 230, 36, 210, 7, 250, 27, 166, 116, 104,
+			36, 210, 7, 250, 27, 102, 126, 84, 55, 101, 82, 251, 175, 77,
+			251, 142, 226, 253, 233, 33, 53, 64, 113, 0, 10, 147, 148, 181,
+			232, 197, 92, 135, 177, 232, 153, 82, 85, 51, 116, 250, 175, 77,
+			233, 230, 73, 164, 79, 243, 95, 155, 99, 227, 104, 148, 33, 192,
+			45, 158, 177, 204, 139, 114, 222, 28, 27, 65, 69, 2, 78, 6,
+			64, 121, 196, 68, 208, 224, 240, 140, 53, 113, 74, 129, 22, 128,
+			147, 138, 151, 56, 57, 0, 53, 47, 113, 178, 0, 222, 243, 32,
+			249, 172, 88, 197, 25, 106, 191, 217, 50, 139, 133, 255, 211, 76,
+			36, 166, 43, 65, 143, 188, 20, 197, 33, 198, 174, 248, 102, 228,
+			165, 234, 38, 11, 68, 204, 147, 210, 158, 74, 229, 230, 153, 14,
+			133, 209, 29, 243, 3, 229, 209, 89, 244, 25, 217, 112, 35, 158,
+			150, 69, 180, 192, 133, 71, 165, 172, 237, 198, 219, 37, 230, 109,
+			234, 104, 31, 101, 81, 52, 229, 235, 145, 46, 21, 197, 110, 44,
+			136, 96, 175, 172, 32, 171, 193, 67, 151, 253, 107, 233, 237, 75,
+			87, 97, 117, 55, 81, 115, 11, 189, 130, 51, 54, 34, 89, 131,
+			14, 128, 122, 191, 207, 24, 0, 74, 59, 31, 65, 247, 160, 55,
+			91, 199, 79, 144, 223, 176, 113, 130, 178, 212, 126, 183, 101, 94,
+			44, 252, 146, 205, 86, 132, 83, 179, 140, 116, 170, 182, 226, 168,
+			219, 210, 226, 249, 98, 23, 111, 186, 254, 86, 199, 221, 226, 15,
+			51, 86, 148, 145, 79, 139, 186, 136, 240, 79, 196, 115, 93, 21,
+			106, 4, 88, 165, 191, 11, 235, 49, 246, 234, 32, 170, 179, 218,
+			181, 121, 22, 237, 70, 49, 218, 44, 86, 241, 160, 55, 76, 183,
+			132, 55, 163, 57, 97, 40, 145, 118, 179, 249, 104, 79, 55, 216,
+			148, 14, 75, 208, 16, 161, 94, 220, 166, 212, 216, 162, 233, 50,
+			97, 87, 122, 7, 181, 195, 165, 249, 4, 149, 162, 27, 158, 143,
+			86, 99, 37, 126, 200, 150, 128, 177, 38, 14, 226, 120, 210, 231,
+			134, 205, 93, 229, 18, 141, 214, 158, 222, 104, 77, 100, 159, 198,
+			4, 54, 118, 132, 7, 128, 112, 161, 72, 185, 162, 123, 62, 219,
+			116, 111, 6, 232, 58, 37, 86, 186, 236, 56, 17, 170, 121, 247,
+			118, 150, 198, 168, 80, 67, 111, 141, 210, 144, 111, 6, 33, 47,
+			17, 201, 146, 180, 159, 91, 192, 208, 43, 174, 204, 216, 50, 94,
+			174, 109, 112, 169, 184, 11, 29, 60, 130, 253, 99, 15, 25, 8,
+			159, 119, 130, 234, 171, 87, 247, 98, 80, 34, 240, 212, 158, 183,
+			132, 251, 65, 39, 217, 84, 178, 54, 146, 149, 6, 29, 0, 245,
+			166, 146, 53, 0, 212, 210, 73, 214, 2, 80, 75, 39, 217, 28,
+			128, 154, 163, 100, 145, 66, 239, 121, 80, 114, 174, 28, 181, 191,
+			223, 50, 213, 142, 147, 179, 17, 84, 237, 228, 28, 0, 117, 59,
+			57, 3, 192, 252, 105, 5, 90, 0, 78, 205, 40, 16, 171, 58,
+			243, 50, 5, 102, 1, 188, 239, 162, 108, 167, 159, 218, 239, 73,
+			56, 100, 191, 141, 160, 106, 167, 223, 1, 80, 183, 211, 111, 0,
+			168, 199, 211, 111, 1, 168, 199, 211, 159, 3, 80, 143, 167, 63,
+			11, 160, 30, 15, 161, 246, 15, 88, 166, 234, 4, 177, 17, 84,
+			237, 16, 7, 64, 221, 14, 168, 163, 63, 96, 229, 21, 39, 38,
+			22, 128, 147, 211, 10, 204, 1, 56, 163, 186, 76, 178, 0, 222,
+			123, 129, 60, 47, 116, 215, 1, 106, 127, 208, 50, 207, 23, 254,
+			192, 96, 213, 168, 43, 144, 136, 34, 196, 135, 9, 19, 79, 252,
+			0, 9, 6, 130, 127, 197, 110, 184, 197, 99, 224, 186, 241, 102,
+			16, 182, 164, 163, 21, 236, 221, 188, 229, 197, 144, 63, 185, 80,
+			161, 109, 182, 68, 8, 251, 55, 121, 184, 139, 242, 98, 90, 128,
+			69, 131, 150, 23, 107, 38, 173, 182, 236, 230, 46, 243, 182, 252,
+			32, 228, 141, 139, 42, 59, 148, 39, 172, 201, 221, 40, 78, 59,
+			155, 225, 117, 10, 181, 147, 99, 75, 106, 8, 66, 12, 107, 166,
+			84, 215, 1, 27, 71, 173, 65, 7, 64, 141, 207, 1, 3, 192,
+			124, 65, 129, 22, 128, 71, 143, 43, 48, 7, 32, 187, 75, 129,
+			89, 0, 207, 156, 35, 223, 137, 232, 28, 164, 246, 15, 90, 230,
+			253, 133, 215, 49, 17, 157, 57, 82, 206, 67, 120, 248, 132, 161,
+			154, 181, 234, 42, 131, 136, 236, 23, 199, 69, 90, 85, 209, 8,
+			200, 209, 165, 76, 196, 94, 217, 218, 35, 47, 65, 101, 243, 103,
+			206, 232, 161, 13, 218, 216, 3, 13, 58, 0, 234, 161, 225, 117,
+			11, 75, 158, 18, 16, 115, 208, 2, 240, 164, 90, 9, 131, 57,
+			0, 239, 188, 79, 129, 89, 0, 239, 186, 151, 252, 51, 65, 42,
+			67, 212, 254, 17, 203, 156, 46, 188, 49, 101, 230, 8, 148, 37,
+			140, 213, 165, 34, 36, 34, 64, 75, 198, 225, 33, 140, 6, 60,
+			212, 132, 64, 217, 77, 21, 33, 251, 13, 168, 103, 143, 47, 11,
+			142, 37, 124, 155, 20, 175, 210, 195, 29, 178, 177, 83, 26, 116,
+			0, 212, 155, 220, 144, 1, 32, 85, 43, 112, 200, 2, 240, 206,
+			41, 82, 197, 241, 12, 83, 251, 71, 45, 115, 170, 112, 145, 233,
+			56, 210, 136, 206, 61, 125, 186, 168, 154, 141, 148, 255, 182, 220,
+			216, 117, 55, 134, 109, 172, 75, 131, 14, 128, 186, 27, 195, 6,
+			128, 180, 168, 64, 11, 192, 211, 147, 228, 29, 66, 24, 26, 161,
+			246, 143, 89, 230, 169, 194, 119, 153, 41, 107, 15, 91, 217, 241,
+			54, 227, 244, 222, 134, 75, 3, 111, 132, 237, 181, 3, 129, 220,
+			55, 175, 156, 68, 209, 233, 35, 228, 32, 18, 1, 233, 79, 150,
+			39, 133, 172, 223, 241, 27, 60, 140, 234, 65, 200, 117, 128, 57,
+			225, 95, 18, 168, 73, 83, 62, 225, 209, 217, 104, 183, 181, 17,
+			52, 35, 162, 148, 77, 233, 185, 25, 39, 170, 69, 36, 38, 87,
+			136, 63, 37, 225, 146, 133, 93, 212, 86, 124, 233, 119, 43, 142,
+			29, 200, 11, 53, 163, 91, 81, 8, 28, 177, 17, 39, 26, 116,
+			0, 212, 248, 28, 49, 0, 212, 182, 165, 17, 11, 192, 19, 39,
+			209, 13, 158, 152, 121, 106, 255, 111, 48, 173, 237, 132, 74, 219,
+			219, 237, 219, 165, 78, 200, 186, 135, 0, 200, 62, 84, 185, 32,
+			231, 201, 139, 24, 104, 136, 187, 186, 239, 121, 27, 59, 160, 65,
+			7, 64, 221, 247, 188, 1, 160, 166, 133, 188, 5, 224, 233, 73,
+			242, 127, 136, 53, 54, 74, 237, 127, 99, 153, 167, 11, 207, 25,
+			168, 78, 164, 240, 141, 134, 4, 17, 153, 80, 135, 133, 67, 146,
+			13, 54, 247, 239, 180, 238, 34, 73, 250, 184, 103, 22, 245, 167,
+			110, 129, 21, 197, 205, 180, 27, 51, 44, 12, 32, 134, 152, 135,
+			173, 36, 150, 144, 238, 132, 30, 252, 168, 141, 3, 208, 160, 3,
+			160, 30, 252, 168, 1, 32, 85, 172, 116, 212, 2, 176, 120, 138,
+			252, 170, 24, 60, 165, 246, 39, 45, 179, 92, 248, 223, 255, 22,
+			131, 87, 113, 227, 53, 22, 200, 222, 153, 122, 81, 44, 36, 22,
+			183, 52, 34, 136, 198, 196, 109, 32, 130, 218, 56, 24, 13, 58,
+			0, 106, 68, 80, 3, 64, 170, 100, 1, 106, 1, 120, 102, 150,
+			252, 130, 64, 196, 24, 181, 255, 45, 112, 132, 31, 127, 49, 68,
+			168, 249, 10, 54, 89, 216, 217, 216, 253, 91, 16, 129, 244, 182,
+			252, 150, 200, 0, 155, 238, 101, 137, 99, 54, 14, 66, 131, 14,
+			128, 26, 1, 99, 6, 128, 122, 9, 143, 89, 0, 158, 56, 73,
+			94, 143, 227, 31, 167, 246, 175, 88, 230, 133, 130, 255, 45, 221,
+			10, 39, 250, 72, 162, 59, 96, 163, 218, 119, 139, 203, 234, 172,
+			89, 157, 81, 164, 175, 144, 19, 115, 220, 198, 230, 53, 152, 1,
+			112, 64, 117, 117, 220, 0, 80, 94, 33, 39, 230, 184, 5, 224,
+			125, 15, 146, 55, 27, 196, 178, 65, 50, 251, 53, 203, 60, 84,
+			120, 250, 111, 125, 135, 252, 91, 31, 5, 74, 144, 78, 31, 244,
+			68, 94, 56, 39, 120, 225, 252, 215, 44, 121, 225, 156, 224, 133,
+			243, 95, 179, 238, 152, 16, 199, 25, 253, 212, 254, 15, 150, 57,
+			44, 10, 246, 247, 1, 52, 48, 36, 114, 246, 67, 193, 52, 104,
+			74, 112, 144, 152, 246, 0, 205, 252, 186, 213, 247, 125, 182, 129,
+			213, 128, 216, 244, 235, 86, 238, 0, 249, 45, 135, 216, 246, 128,
+			217, 71, 237, 207, 89, 230, 203, 11, 191, 226, 0, 35, 70, 69,
+			32, 117, 152, 149, 248, 217, 223, 165, 44, 24, 144, 43, 125, 51,
+			118, 179, 235, 22, 147, 50, 108, 165, 118, 29, 204, 161, 143, 41,
+			102, 65, 102, 116, 99, 111, 195, 195, 104, 60, 218, 188, 213, 83,
+			59, 145, 213, 151, 25, 30, 143, 201, 43, 187, 201, 1, 167, 39,
+			100, 170, 36, 140, 157, 184, 231, 126, 129, 177, 106, 60, 25, 177,
+			38, 143, 34, 194, 248, 230, 166, 87, 247, 240, 94, 215, 54, 72,
+			116, 124, 135, 135, 108, 147, 187, 113, 39, 228, 145, 48, 89, 195,
+			84, 194, 86, 139, 146, 44, 58, 40, 55, 122, 66, 247, 105, 255,
+			121, 101, 208, 229, 79, 185, 24, 100, 175, 235, 248, 152, 233, 236,
+			151, 131, 128, 253, 3, 17, 62, 84, 174, 219, 91, 188, 10, 193,
+			30, 66, 108, 95, 20, 121, 83, 52, 118, 15, 76, 64, 203, 125,
+			10, 191, 188, 161, 219, 199, 148, 167, 78, 251, 65, 102, 23, 135,
+			229, 128, 6, 213, 61, 225, 154, 125, 49, 133, 208, 72, 122, 254,
+			97, 214, 244, 84, 17, 188, 225, 212, 77, 221, 168, 149, 162, 231,
+			147, 26, 55, 40, 181, 168, 160, 42, 25, 248, 162, 136, 185, 134,
+			178, 132, 242, 104, 216, 144, 254, 167, 145, 56, 15, 136, 80, 83,
+			232, 245, 253, 199, 38, 47, 105, 239, 112, 169, 19, 168, 227, 59,
+			193, 212, 132, 199, 73, 188, 131, 103, 192, 113, 232, 213, 117, 204,
+			91, 156, 125, 238, 111, 6, 97, 93, 234, 243, 241, 126, 129, 246,
+			36, 131, 24, 192, 147, 175, 207, 41, 6, 49, 128, 39, 95, 159,
+			83, 50, 246, 0, 154, 14, 63, 103, 229, 39, 21, 104, 1, 56,
+			115, 70, 129, 57, 0, 165, 109, 116, 192, 236, 203, 2, 120, 255,
+			203, 200, 23, 13, 92, 52, 6, 181, 255, 200, 50, 47, 23, 254,
+			171, 193, 22, 196, 57, 145, 144, 96, 82, 150, 7, 105, 5, 82,
+			15, 122, 176, 98, 35, 117, 144, 80, 100, 234, 81, 15, 229, 191,
+			89, 119, 125, 12, 27, 187, 217, 244, 234, 177, 186, 62, 41, 44,
+			202, 170, 38, 229, 167, 161, 110, 111, 33, 23, 114, 49, 24, 21,
+			107, 129, 192, 169, 131, 171, 73, 65, 72, 174, 91, 238, 70, 30,
+			15, 47, 50, 159, 239, 72, 35, 132, 88, 76, 238, 205, 192, 83,
+			228, 34, 207, 84, 82, 157, 44, 106, 60, 26, 54, 142, 86, 131,
+			14, 128, 26, 143, 6, 226, 34, 127, 86, 129, 22, 128, 231, 239,
+			86, 96, 14, 192, 123, 42, 10, 204, 2, 248, 240, 2, 249, 146,
+			192, 163, 73, 237, 47, 91, 230, 93, 133, 223, 77, 212, 90, 69,
+			222, 47, 153, 102, 155, 90, 67, 223, 164, 58, 43, 181, 89, 114,
+			219, 234, 108, 138, 236, 197, 248, 77, 27, 7, 172, 65, 7, 64,
+			141, 74, 32, 171, 47, 43, 141, 118, 0, 207, 125, 190, 108, 29,
+			61, 166, 192, 28, 128, 199, 207, 41, 48, 11, 224, 204, 89, 193,
+			212, 251, 169, 253, 21, 203, 28, 195, 189, 97, 0, 246, 134, 175,
+			88, 3, 131, 34, 39, 238, 13, 105, 208, 148, 160, 200, 139, 31,
+			135, 101, 23, 250, 141, 30, 208, 148, 160, 200, 139, 208, 40, 149,
+			31, 77, 163, 27, 84, 95, 63, 148, 193, 185, 181, 168, 253, 61,
+			182, 121, 172, 240, 142, 12, 72, 50, 250, 134, 142, 154, 95, 177,
+			242, 187, 79, 243, 83, 242, 161, 219, 70, 191, 252, 93, 193, 138,
+			228, 188, 17, 72, 87, 215, 184, 212, 117, 37, 157, 34, 216, 48,
+			131, 148, 151, 189, 130, 239, 174, 238, 182, 121, 137, 97, 216, 124,
+			248, 249, 114, 72, 95, 23, 43, 234, 33, 118, 215, 69, 146, 8,
+			45, 141, 244, 21, 171, 102, 16, 220, 136, 48, 52, 135, 170, 78,
+			118, 248, 170, 219, 70, 231, 78, 124, 28, 71, 113, 248, 52, 151,
+			87, 15, 233, 116, 243, 245, 36, 135, 219, 100, 178, 91, 236, 6,
+			223, 149, 157, 216, 147, 69, 119, 88, 42, 102, 15, 177, 243, 50,
+			219, 27, 196, 31, 205, 84, 187, 59, 212, 51, 58, 194, 170, 61,
+			81, 47, 208, 153, 110, 59, 8, 34, 193, 72, 83, 102, 10, 49,
+			47, 170, 251, 15, 161, 8, 160, 87, 200, 70, 39, 70, 137, 154,
+			185, 204, 23, 17, 76, 96, 110, 188, 174, 101, 168, 237, 184, 113,
+			192, 182, 65, 98, 128, 111, 55, 248, 174, 184, 248, 46, 29, 241,
+			5, 194, 83, 7, 107, 115, 215, 170, 40, 93, 225, 93, 135, 61,
+			49, 58, 240, 24, 77, 57, 221, 96, 236, 85, 55, 34, 204, 219,
+			76, 238, 98, 138, 21, 184, 255, 253, 50, 188, 154, 177, 188, 90,
+			185, 160, 34, 76, 74, 99, 167, 22, 165, 123, 34, 236, 178, 57,
+			225, 130, 160, 196, 30, 164, 42, 17, 145, 143, 40, 221, 88, 92,
+			83, 149, 21, 72, 30, 170, 156, 195, 188, 86, 151, 89, 85, 88,
+			191, 165, 134, 162, 54, 38, 233, 205, 150, 108, 80, 150, 141, 75,
+			68, 131, 14, 128, 154, 27, 88, 6, 128, 249, 67, 10, 196, 245,
+			116, 228, 40, 57, 37, 215, 251, 63, 177, 205, 161, 226, 65, 60,
+			3, 111, 122, 49, 72, 23, 242, 140, 108, 163, 201, 137, 92, 176,
+			22, 100, 211, 43, 31, 106, 76, 131, 166, 4, 143, 203, 26, 223,
+			10, 53, 82, 172, 209, 119, 253, 96, 221, 141, 214, 161, 102, 85,
+			153, 13, 57, 116, 105, 219, 232, 6, 77, 9, 46, 227, 226, 183,
+			169, 253, 118, 251, 219, 23, 48, 106, 64, 92, 78, 72, 112, 101,
+			103, 0, 28, 80, 188, 17, 47, 39, 216, 199, 213, 174, 131, 151,
+			19, 108, 25, 48, 106, 192, 161, 246, 59, 237, 151, 38, 96, 212,
+			0, 200, 239, 239, 180, 165, 252, 62, 128, 242, 251, 59, 109, 41,
+			191, 15, 160, 252, 254, 78, 251, 142, 9, 20, 195, 7, 105, 230,
+			221, 118, 223, 159, 72, 49, 124, 208, 160, 246, 187, 237, 220, 56,
+			249, 65, 147, 216, 246, 32, 136, 225, 239, 181, 205, 114, 225, 251,
+			76, 196, 24, 62, 85, 149, 16, 171, 58, 99, 67, 7, 174, 51,
+			103, 122, 207, 205, 165, 188, 238, 38, 14, 180, 228, 22, 23, 238,
+			165, 203, 230, 182, 235, 3, 250, 181, 63, 209, 14, 16, 115, 153,
+			105, 93, 70, 29, 83, 16, 141, 152, 13, 222, 12, 118, 148, 228,
+			209, 173, 143, 238, 242, 56, 89, 190, 137, 195, 67, 208, 230, 234,
+			93, 4, 232, 13, 236, 157, 156, 205, 206, 178, 40, 8, 195, 221,
+			18, 219, 225, 147, 205, 38, 67, 14, 31, 136, 139, 79, 13, 142,
+			151, 35, 209, 209, 181, 3, 34, 186, 58, 169, 57, 33, 102, 125,
+			16, 37, 186, 247, 42, 34, 24, 196, 168, 97, 239, 181, 229, 109,
+			142, 65, 148, 232, 222, 107, 203, 56, 30, 131, 40, 209, 189, 215,
+			62, 84, 80, 96, 14, 192, 195, 179, 10, 204, 2, 120, 103, 9,
+			131, 178, 12, 218, 125, 52, 243, 62, 219, 252, 65, 91, 4, 101,
+			25, 196, 243, 234, 247, 217, 89, 88, 27, 25, 0, 97, 126, 62,
+			96, 219, 35, 133, 17, 109, 167, 104, 97, 116, 82, 60, 163, 29,
+			148, 71, 209, 31, 176, 237, 84, 130, 9, 9, 67, 195, 232, 34,
+			49, 40, 142, 162, 63, 100, 203, 3, 228, 65, 121, 162, 252, 33,
+			219, 206, 37, 9, 38, 36, 12, 12, 234, 18, 38, 181, 63, 108,
+			203, 48, 60, 131, 242, 96, 248, 195, 182, 60, 24, 30, 148, 7,
+			195, 31, 182, 199, 198, 201, 111, 11, 42, 50, 168, 253, 172, 109,
+			30, 46, 124, 202, 148, 235, 14, 239, 63, 203, 233, 146, 103, 246,
+			210, 43, 72, 92, 124, 85, 204, 179, 29, 122, 45, 124, 6, 71,
+			201, 131, 232, 96, 138, 172, 132, 185, 66, 67, 210, 202, 212, 30,
+			210, 18, 243, 13, 154, 77, 153, 213, 92, 185, 211, 187, 190, 174,
+			29, 116, 143, 157, 208, 83, 222, 137, 24, 169, 80, 69, 68, 73,
+			60, 139, 184, 32, 160, 82, 250, 74, 155, 27, 134, 238, 46, 154,
+			68, 68, 244, 45, 220, 3, 180, 243, 109, 179, 55, 102, 210, 70,
+			51, 216, 40, 179, 170, 186, 105, 94, 18, 236, 89, 29, 121, 1,
+			103, 142, 69, 60, 115, 188, 76, 142, 167, 104, 210, 1, 13, 69,
+			97, 121, 124, 39, 144, 150, 138, 236, 35, 40, 6, 47, 100, 36,
+			196, 135, 23, 50, 20, 183, 30, 20, 23, 50, 236, 188, 34, 62,
+			188, 144, 1, 196, 247, 207, 29, 156, 24, 147, 218, 63, 105, 155,
+			119, 23, 254, 137, 131, 19, 35, 94, 138, 211, 222, 57, 210, 80,
+			195, 19, 159, 196, 21, 20, 71, 4, 134, 180, 57, 75, 250, 138,
+			7, 50, 10, 130, 188, 205, 158, 222, 149, 64, 235, 214, 175, 137,
+			224, 224, 161, 220, 125, 247, 176, 13, 92, 89, 49, 223, 10, 221,
+			38, 226, 126, 211, 123, 74, 133, 75, 33, 108, 202, 243, 227, 251,
+			238, 41, 177, 142, 252, 27, 201, 191, 152, 9, 19, 228, 175, 233,
+			50, 99, 115, 169, 16, 119, 106, 32, 250, 185, 55, 34, 34, 22,
+			73, 250, 192, 9, 75, 143, 71, 120, 219, 40, 157, 7, 177, 30,
+			177, 102, 32, 34, 6, 128, 220, 236, 161, 39, 141, 112, 250, 1,
+			122, 221, 118, 219, 192, 70, 118, 68, 60, 130, 38, 136, 27, 73,
+			160, 8, 25, 129, 65, 90, 133, 217, 102, 51, 16, 98, 183, 112,
+			40, 79, 154, 45, 19, 182, 130, 12, 109, 23, 190, 234, 183, 232,
+			180, 54, 32, 7, 129, 106, 105, 151, 30, 199, 27, 233, 206, 203,
+			187, 11, 68, 11, 9, 169, 111, 69, 225, 122, 90, 84, 49, 113,
+			208, 57, 129, 111, 187, 55, 189, 32, 76, 221, 171, 64, 198, 33,
+			230, 138, 48, 253, 100, 30, 94, 253, 236, 146, 127, 116, 52, 237,
+			88, 24, 35, 186, 120, 174, 190, 3, 28, 200, 201, 78, 159, 154,
+			11, 135, 58, 225, 86, 235, 54, 208, 230, 47, 220, 161, 183, 130,
+			96, 171, 220, 114, 227, 237, 114, 21, 232, 64, 139, 33, 131, 168,
+			148, 252, 100, 66, 216, 102, 6, 64, 105, 2, 28, 68, 158, 243,
+			147, 54, 157, 80, 160, 5, 224, 225, 35, 10, 204, 1, 120, 244,
+			188, 2, 179, 0, 78, 221, 37, 185, 170, 65, 51, 63, 101, 155,
+			63, 167, 185, 42, 44, 146, 159, 178, 179, 67, 24, 84, 107, 80,
+			132, 186, 250, 25, 219, 166, 133, 131, 210, 128, 154, 58, 229, 22,
+			119, 161, 4, 163, 19, 113, 174, 126, 38, 97, 158, 34, 206, 213,
+			207, 216, 249, 81, 50, 45, 171, 50, 168, 253, 179, 80, 213, 33,
+			172, 106, 15, 205, 69, 169, 202, 12, 145, 55, 169, 12, 24, 239,
+			207, 166, 43, 51, 169, 253, 201, 125, 43, 75, 60, 140, 85, 89,
+			104, 248, 147, 233, 202, 68, 225, 252, 40, 249, 239, 131, 184, 244,
+			45, 106, 255, 142, 109, 158, 41, 252, 222, 160, 242, 209, 72, 93,
+			158, 216, 208, 42, 72, 211, 125, 218, 107, 238, 62, 204, 216, 162,
+			251, 244, 174, 58, 82, 212, 39, 138, 82, 2, 153, 5, 180, 168,
+			80, 174, 226, 90, 64, 139, 187, 190, 140, 125, 177, 163, 156, 235,
+			132, 95, 105, 74, 211, 194, 251, 60, 184, 211, 139, 214, 74, 130,
+			131, 120, 24, 37, 72, 230, 155, 140, 146, 232, 57, 200, 20, 229,
+			117, 82, 217, 191, 141, 78, 172, 4, 97, 33, 200, 9, 111, 18,
+			193, 151, 133, 181, 78, 210, 125, 87, 173, 146, 191, 214, 99, 225,
+			17, 159, 212, 135, 157, 149, 161, 161, 48, 138, 147, 176, 136, 40,
+			243, 155, 244, 145, 134, 225, 187, 120, 247, 7, 70, 219, 163, 35,
+			184, 33, 103, 155, 33, 231, 194, 218, 142, 154, 141, 142, 5, 129,
+			18, 17, 97, 220, 221, 226, 33, 104, 249, 77, 192, 170, 186, 154,
+			211, 29, 169, 68, 95, 195, 209, 242, 158, 186, 51, 163, 29, 253,
+			136, 222, 59, 186, 140, 252, 160, 40, 69, 157, 173, 45, 30, 169,
+			240, 35, 93, 22, 41, 23, 223, 32, 1, 209, 201, 227, 34, 104,
+			143, 139, 186, 20, 212, 211, 213, 159, 174, 104, 53, 24, 201, 51,
+			8, 165, 89, 52, 181, 180, 55, 130, 224, 198, 13, 206, 69, 124,
+			172, 224, 38, 15, 183, 97, 46, 226, 221, 182, 212, 158, 101, 188,
+			242, 46, 111, 54, 111, 15, 3, 81, 174, 160, 204, 21, 206, 134,
+			113, 234, 133, 1, 63, 230, 225, 166, 60, 175, 113, 253, 174, 115,
+			138, 160, 1, 10, 173, 219, 108, 42, 31, 88, 12, 197, 130, 134,
+			84, 22, 242, 150, 155, 186, 11, 89, 102, 236, 114, 39, 132, 105,
+			0, 185, 1, 72, 45, 228, 110, 99, 54, 114, 55, 185, 14, 191,
+			78, 82, 141, 121, 233, 254, 164, 30, 85, 16, 29, 190, 136, 126,
+			55, 177, 242, 200, 83, 141, 65, 109, 200, 140, 97, 236, 194, 220,
+			165, 175, 77, 136, 6, 145, 156, 235, 157, 80, 220, 39, 195, 61,
+			167, 41, 66, 146, 116, 87, 8, 68, 239, 249, 29, 78, 196, 189,
+			19, 140, 201, 193, 184, 138, 46, 44, 201, 178, 76, 186, 238, 250,
+			247, 106, 171, 123, 117, 107, 124, 194, 91, 135, 248, 80, 82, 149,
+			184, 62, 68, 112, 43, 123, 122, 183, 251, 130, 134, 240, 138, 247,
+			162, 18, 14, 9, 200, 162, 26, 85, 197, 186, 245, 158, 230, 141,
+			169, 105, 37, 104, 117, 173, 110, 130, 109, 135, 60, 238, 132, 146,
+			32, 49, 216, 136, 212, 147, 187, 151, 226, 182, 27, 49, 124, 35,
+			10, 151, 64, 87, 207, 82, 102, 123, 159, 195, 128, 221, 112, 87,
+			95, 70, 8, 148, 163, 219, 62, 117, 162, 230, 32, 47, 147, 5,
+			194, 199, 79, 172, 111, 207, 23, 1, 103, 228, 54, 133, 79, 128,
+			97, 116, 42, 64, 76, 9, 248, 60, 119, 181, 68, 216, 238, 132,
+			237, 64, 120, 95, 0, 98, 136, 90, 25, 32, 110, 248, 189, 123,
+			163, 52, 152, 34, 186, 163, 23, 196, 55, 209, 54, 108, 29, 189,
+			37, 150, 239, 13, 120, 113, 26, 227, 234, 40, 33, 229, 141, 149,
+			154, 27, 197, 45, 187, 187, 129, 85, 203, 215, 193, 102, 196, 13,
+			134, 25, 217, 13, 15, 31, 154, 235, 234, 10, 154, 26, 103, 208,
+			97, 121, 134, 188, 80, 182, 110, 222, 164, 248, 153, 136, 96, 211,
+			101, 64, 219, 6, 89, 120, 131, 115, 95, 98, 92, 239, 231, 150,
+			141, 59, 142, 6, 29, 0, 181, 160, 106, 25, 0, 202, 144, 128,
+			131, 104, 86, 248, 29, 251, 160, 218, 222, 173, 28, 128, 135, 102,
+			20, 152, 5, 240, 212, 52, 249, 130, 129, 123, 153, 77, 237, 63,
+			176, 205, 187, 10, 255, 37, 237, 134, 4, 60, 235, 37, 179, 214,
+			42, 59, 121, 244, 173, 217, 106, 101, 12, 192, 219, 114, 61, 146,
+			209, 91, 197, 200, 109, 49, 84, 13, 58, 0, 106, 36, 218, 6,
+			128, 121, 165, 91, 218, 22, 128, 210, 82, 59, 136, 142, 213, 127,
+			96, 75, 75, 237, 32, 58, 86, 255, 129, 61, 115, 150, 60, 140,
+			56, 116, 168, 253, 135, 182, 89, 42, 220, 245, 205, 63, 144, 34,
+			234, 115, 108, 172, 65, 131, 88, 161, 238, 154, 99, 0, 168, 231,
+			215, 177, 0, 60, 120, 72, 129, 57, 0, 11, 103, 20, 152, 5,
+			240, 244, 12, 90, 109, 6, 65, 242, 251, 226, 183, 209, 106, 51,
+			136, 206, 173, 95, 76, 186, 154, 193, 6, 6, 20, 158, 50, 6,
+			128, 210, 106, 51, 136, 206, 173, 95, 84, 86, 155, 65, 135, 218,
+			95, 122, 137, 172, 54, 131, 78, 31, 84, 46, 173, 54, 131, 104,
+			181, 249, 146, 178, 218, 12, 162, 213, 230, 75, 246, 29, 19, 228,
+			8, 244, 163, 159, 218, 95, 182, 205, 161, 226, 136, 124, 120, 164,
+			193, 158, 68, 33, 76, 212, 212, 223, 7, 159, 165, 21, 108, 16,
+			77, 237, 105, 208, 148, 224, 32, 49, 237, 33, 154, 121, 222, 238,
+			251, 75, 105, 255, 25, 50, 168, 253, 188, 157, 27, 71, 204, 15,
+			129, 32, 252, 103, 223, 70, 204, 15, 161, 169, 228, 207, 20, 230,
+			135, 208, 84, 242, 103, 10, 243, 67, 40, 78, 255, 153, 194, 252,
+			16, 154, 74, 254, 76, 97, 126, 200, 161, 246, 87, 95, 34, 204,
+			15, 1, 230, 191, 170, 48, 63, 132, 152, 255, 170, 194, 252, 16,
+			98, 254, 171, 202, 94, 54, 76, 51, 127, 101, 247, 125, 175, 35,
+			240, 53, 108, 80, 251, 175, 236, 220, 24, 121, 130, 216, 246, 48,
+			224, 235, 111, 108, 147, 21, 106, 226, 208, 186, 219, 243, 66, 157,
+			97, 227, 235, 221, 172, 37, 94, 233, 74, 108, 99, 226, 165, 4,
+			124, 209, 41, 78, 174, 117, 16, 21, 140, 18, 251, 50, 140, 8,
+			252, 27, 133, 192, 97, 60, 61, 252, 27, 181, 202, 134, 17, 129,
+			127, 99, 231, 15, 43, 208, 2, 240, 216, 113, 242, 57, 3, 187,
+			103, 80, 251, 77, 142, 121, 87, 225, 55, 19, 62, 41, 67, 200,
+			188, 132, 135, 90, 226, 30, 214, 75, 203, 37, 241, 108, 87, 227,
+			200, 176, 113, 156, 26, 116, 0, 212, 56, 50, 16, 11, 146, 73,
+			14, 163, 73, 228, 77, 142, 100, 146, 195, 120, 50, 248, 38, 71,
+			50, 201, 97, 60, 25, 124, 147, 51, 115, 22, 141, 209, 195, 253,
+			212, 126, 179, 243, 2, 198, 232, 97, 88, 119, 111, 118, 228, 66,
+			27, 198, 117, 151, 6, 77, 9, 46, 227, 108, 152, 212, 126, 139,
+			243, 237, 91, 92, 195, 168, 49, 191, 37, 25, 55, 240, 205, 183,
+			56, 3, 106, 100, 48, 251, 111, 113, 228, 226, 26, 70, 141, 249,
+			45, 142, 92, 92, 195, 14, 181, 223, 230, 188, 52, 139, 107, 24,
+			22, 215, 219, 28, 185, 184, 134, 113, 113, 189, 205, 145, 139, 107,
+			24, 23, 215, 219, 28, 185, 184, 70, 104, 230, 237, 78, 223, 251,
+			228, 226, 26, 49, 168, 253, 118, 39, 55, 65, 254, 2, 200, 119,
+			4, 163, 241, 0, 249, 126, 161, 135, 124, 133, 150, 244, 146, 19,
+			177, 104, 231, 165, 62, 156, 77, 197, 122, 146, 19, 59, 34, 2,
+			15, 169, 137, 29, 17, 129, 135, 20, 65, 143, 136, 192, 67, 138,
+			160, 71, 68, 224, 33, 69, 208, 35, 104, 96, 126, 151, 34, 232,
+			17, 52, 48, 191, 11, 8, 122, 25, 81, 106, 80, 251, 61, 223,
+			70, 26, 28, 193, 181, 247, 158, 164, 171, 70, 6, 192, 1, 213,
+			25, 3, 219, 147, 52, 56, 130, 107, 239, 61, 138, 6, 71, 28,
+			106, 191, 247, 37, 162, 193, 17, 160, 193, 247, 42, 26, 28, 65,
+			26, 124, 175, 162, 193, 17, 164, 193, 247, 42, 26, 204, 211, 204,
+			7, 156, 190, 127, 45, 105, 48, 111, 80, 251, 3, 78, 238, 0,
+			249, 47, 22, 177, 237, 60, 208, 224, 179, 142, 121, 62, 237, 26,
+			144, 92, 47, 125, 9, 9, 80, 54, 242, 82, 83, 159, 186, 152,
+			81, 38, 231, 191, 96, 160, 102, 121, 129, 137, 224, 65, 58, 24,
+			196, 93, 58, 10, 196, 221, 231, 85, 224, 161, 36, 18, 188, 126,
+			17, 91, 139, 142, 181, 107, 243, 132, 49, 182, 25, 186, 45, 190,
+			19, 132, 55, 202, 140, 61, 198, 153, 219, 14, 154, 193, 22, 80,
+			18, 62, 12, 18, 184, 97, 67, 106, 93, 81, 234, 5, 139, 128,
+			5, 157, 48, 226, 205, 155, 60, 146, 71, 190, 140, 237, 112, 113,
+			207, 70, 69, 112, 20, 134, 11, 188, 49, 130, 177, 89, 55, 240,
+			126, 10, 100, 107, 240, 186, 39, 77, 15, 234, 160, 71, 61, 161,
+			10, 21, 201, 87, 84, 37, 233, 230, 113, 149, 61, 171, 72, 55,
+			143, 171, 236, 89, 181, 202, 242, 184, 202, 158, 85, 171, 44, 143,
+			171, 236, 89, 71, 250, 245, 231, 113, 149, 61, 235, 72, 191, 254,
+			60, 174, 178, 103, 157, 51, 231, 112, 149, 229, 97, 149, 125, 236,
+			219, 184, 202, 242, 184, 202, 62, 150, 116, 21, 86, 217, 199, 212,
+			42, 203, 227, 42, 251, 152, 90, 101, 121, 92, 101, 31, 83, 171,
+			44, 239, 80, 251, 185, 151, 104, 149, 229, 97, 149, 61, 167, 86,
+			89, 30, 87, 217, 115, 106, 149, 229, 113, 149, 61, 167, 86, 217,
+			40, 205, 124, 220, 233, 251, 15, 114, 149, 141, 26, 212, 254, 184,
+			147, 187, 131, 252, 38, 172, 178, 81, 88, 101, 159, 128, 85, 246,
+			95, 211, 14, 56, 104, 223, 121, 137, 253, 111, 160, 141, 151, 222,
+			253, 70, 94, 226, 252, 255, 219, 18, 27, 197, 37, 246, 9, 69,
+			183, 163, 184, 196, 62, 161, 150, 216, 40, 46, 177, 79, 168, 37,
+			54, 138, 75, 236, 19, 106, 137, 141, 226, 18, 251, 132, 90, 98,
+			163, 184, 196, 62, 1, 75, 236, 203, 32, 28, 140, 218, 125, 52,
+			243, 11, 142, 249, 239, 28, 171, 219, 107, 75, 26, 5, 27, 124,
+			86, 220, 98, 158, 69, 203, 236, 84, 16, 10, 83, 157, 231, 179,
+			71, 86, 87, 175, 193, 154, 108, 186, 126, 157, 79, 139, 201, 111,
+			240, 86, 59, 136, 185, 15, 211, 26, 132, 204, 23, 246, 148, 135,
+			69, 94, 124, 137, 23, 175, 197, 245, 154, 94, 18, 115, 219, 149,
+			202, 42, 16, 199, 134, 184, 128, 236, 110, 138, 151, 241, 97, 218,
+			133, 127, 231, 181, 181, 212, 247, 164, 57, 109, 253, 83, 38, 237,
+			158, 51, 154, 107, 203, 43, 171, 10, 153, 120, 24, 252, 11, 78,
+			246, 32, 158, 203, 142, 138, 195, 224, 95, 116, 236, 35, 104, 242,
+			31, 149, 103, 191, 191, 232, 200, 39, 51, 70, 229, 217, 239, 47,
+			58, 133, 195, 228, 148, 44, 97, 80, 251, 223, 58, 246, 68, 113,
+			92, 120, 138, 240, 40, 213, 23, 162, 139, 25, 34, 219, 88, 146,
+			96, 66, 194, 129, 131, 228, 126, 89, 143, 73, 237, 95, 114, 236,
+			177, 226, 100, 26, 117, 34, 168, 156, 138, 209, 133, 81, 19, 196,
+			28, 68, 73, 213, 208, 131, 95, 114, 228, 155, 47, 163, 242, 232,
+			248, 151, 28, 124, 90, 8, 24, 129, 65, 51, 191, 236, 152, 255,
+			222, 185, 83, 206, 58, 48, 190, 95, 78, 8, 8, 24, 223, 47,
+			59, 50, 220, 225, 40, 118, 244, 151, 157, 35, 179, 10, 180, 0,
+			148, 193, 1, 70, 81, 180, 255, 247, 78, 230, 180, 2, 179, 0,
+			142, 158, 66, 30, 61, 10, 237, 126, 234, 219, 200, 163, 71, 81,
+			26, 255, 84, 210, 85, 144, 198, 63, 165, 120, 244, 40, 14, 252,
+			83, 138, 71, 143, 162, 52, 254, 41, 197, 163, 71, 29, 106, 127,
+			250, 37, 226, 209, 163, 192, 163, 63, 173, 120, 244, 40, 242, 232,
+			79, 43, 30, 61, 138, 60, 250, 211, 192, 163, 255, 137, 69, 76,
+			155, 210, 204, 111, 57, 125, 255, 175, 99, 20, 190, 110, 178, 57,
+			109, 226, 211, 7, 169, 192, 21, 92, 173, 225, 38, 104, 211, 198,
+			124, 141, 37, 121, 37, 77, 56, 252, 186, 237, 54, 119, 197, 125,
+			91, 53, 10, 25, 98, 75, 4, 123, 86, 215, 116, 181, 139, 210,
+			133, 11, 215, 100, 160, 50, 17, 147, 36, 29, 190, 54, 8, 154,
+			42, 244, 97, 36, 121, 27, 158, 225, 96, 108, 47, 232, 224, 66,
+			42, 78, 57, 94, 30, 139, 202, 93, 215, 88, 123, 186, 224, 249,
+			93, 145, 205, 69, 9, 249, 2, 185, 176, 103, 139, 254, 37, 213,
+			94, 184, 32, 171, 152, 154, 22, 236, 162, 29, 6, 34, 72, 127,
+			79, 182, 249, 160, 189, 187, 26, 76, 77, 79, 203, 131, 44, 12,
+			19, 129, 139, 99, 45, 29, 27, 77, 7, 80, 83, 209, 215, 68,
+			220, 32, 106, 80, 251, 183, 156, 220, 97, 242, 41, 147, 216, 54,
+			181, 250, 104, 230, 119, 28, 243, 255, 113, 172, 194, 191, 17, 110,
+			21, 233, 91, 222, 93, 193, 214, 146, 131, 35, 140, 167, 39, 99,
+			75, 232, 89, 20, 65, 63, 183, 228, 13, 115, 194, 92, 214, 8,
+			226, 89, 21, 111, 165, 161, 156, 123, 189, 104, 61, 9, 42, 33,
+			99, 228, 51, 111, 115, 51, 85, 58, 93, 165, 159, 138, 184, 198,
+			166, 26, 220, 15, 98, 21, 54, 66, 60, 106, 2, 83, 213, 69,
+			3, 81, 155, 215, 163, 94, 23, 184, 233, 50, 97, 149, 242, 86,
+			185, 244, 15, 216, 107, 228, 107, 218, 232, 32, 241, 218, 18, 123,
+			77, 113, 195, 13, 203, 27, 238, 211, 197, 18, 118, 6, 147, 94,
+			215, 121, 74, 103, 97, 111, 72, 245, 136, 136, 87, 184, 167, 100,
+			153, 233, 50, 228, 148, 139, 149, 98, 56, 226, 223, 113, 136, 120,
+			6, 138, 138, 112, 196, 191, 235, 216, 69, 228, 75, 84, 6, 32,
+			254, 93, 71, 134, 7, 166, 50, 0, 241, 239, 58, 67, 99, 73,
+			130, 1, 9, 227, 71, 147, 4, 11, 18, 216, 9, 93, 167, 65,
+			237, 223, 115, 236, 147, 58, 3, 48, 179, 223, 75, 215, 105, 56,
+			144, 48, 52, 154, 36, 96, 17, 122, 44, 73, 176, 32, 225, 68,
+			17, 215, 50, 133, 94, 126, 214, 49, 69, 144, 76, 138, 125, 252,
+			172, 226, 56, 20, 141, 107, 159, 117, 6, 198, 20, 104, 0, 56,
+			62, 161, 64, 11, 192, 195, 71, 196, 123, 66, 20, 58, 247, 121,
+			199, 156, 44, 188, 205, 72, 61, 123, 241, 2, 212, 84, 130, 153,
+			218, 217, 118, 99, 164, 98, 244, 84, 64, 9, 44, 184, 193, 97,
+			193, 135, 4, 182, 2, 17, 175, 18, 163, 6, 186, 145, 122, 165,
+			94, 31, 162, 84, 228, 141, 5, 249, 194, 144, 88, 188, 210, 131,
+			81, 62, 88, 164, 198, 2, 168, 250, 124, 50, 52, 64, 212, 231,
+			29, 233, 12, 64, 17, 77, 159, 119, 228, 181, 56, 138, 72, 250,
+			188, 115, 250, 78, 137, 36, 147, 218, 127, 232, 152, 211, 242, 35,
+			176, 229, 63, 76, 106, 66, 187, 119, 82, 19, 160, 225, 15, 29,
+			122, 74, 129, 22, 128, 147, 83, 178, 38, 139, 218, 95, 112, 100,
+			232, 93, 138, 7, 26, 95, 72, 106, 178, 28, 0, 165, 219, 23,
+			197, 3, 141, 47, 56, 163, 39, 21, 136, 101, 239, 156, 148, 53,
+			217, 212, 254, 111, 142, 169, 62, 218, 2, 84, 53, 217, 14, 128,
+			186, 79, 182, 1, 160, 188, 237, 68, 209, 170, 255, 223, 18, 18,
+			112, 168, 253, 71, 142, 169, 134, 238, 216, 8, 170, 154, 28, 252,
+			170, 251, 228, 24, 0, 142, 42, 122, 113, 44, 0, 101, 80, 85,
+			138, 102, 118, 71, 198, 209, 165, 194, 70, 158, 212, 148, 113, 0,
+			212, 125, 66, 27, 185, 67, 79, 40, 208, 2, 240, 212, 105, 242,
+			219, 6, 49, 237, 49, 154, 121, 222, 233, 251, 211, 140, 81, 120,
+			146, 85, 252, 186, 219, 142, 100, 48, 204, 219, 123, 95, 91, 68,
+			118, 10, 131, 150, 242, 199, 33, 236, 178, 215, 228, 61, 209, 107,
+			217, 142, 155, 10, 238, 81, 38, 231, 159, 248, 118, 134, 31, 237,
+			126, 237, 27, 59, 46, 184, 240, 152, 65, 237, 231, 157, 220, 1,
+			242, 93, 163, 196, 182, 199, 96, 5, 126, 48, 99, 158, 40, 60,
+			159, 103, 115, 108, 49, 144, 49, 23, 189, 36, 84, 171, 203, 218,
+			30, 23, 103, 215, 221, 53, 50, 183, 59, 58, 23, 140, 149, 176,
+			122, 16, 134, 60, 106, 7, 190, 112, 118, 115, 211, 103, 127, 73,
+			4, 84, 125, 215, 162, 251, 105, 112, 17, 244, 79, 62, 221, 144,
+			188, 37, 17, 7, 172, 186, 80, 193, 119, 133, 26, 242, 125, 30,
+			30, 70, 165, 158, 171, 97, 201, 101, 97, 25, 53, 203, 107, 121,
+			77, 55, 36, 250, 81, 113, 249, 48, 20, 70, 223, 43, 177, 200,
+			221, 5, 5, 64, 220, 243, 17, 67, 208, 222, 233, 183, 188, 115,
+			4, 104, 149, 145, 141, 130, 64, 187, 154, 191, 129, 176, 69, 142,
+			215, 163, 130, 224, 6, 115, 99, 17, 175, 53, 241, 11, 77, 198,
+			141, 181, 191, 80, 85, 143, 75, 191, 245, 199, 31, 215, 127, 224,
+			255, 227, 143, 195, 71, 87, 126, 220, 168, 227, 159, 6, 103, 108,
+			147, 177, 173, 109, 143, 128, 186, 164, 35, 143, 234, 136, 46, 172,
+			41, 231, 83, 184, 240, 71, 109, 215, 103, 12, 163, 183, 176, 238,
+			127, 233, 93, 134, 177, 215, 184, 37, 111, 154, 177, 215, 176, 123,
+			74, 236, 92, 137, 157, 47, 177, 115, 236, 181, 152, 15, 24, 235,
+			206, 118, 208, 220, 59, 176, 178, 44, 184, 209, 83, 176, 196, 238,
+			129, 178, 80, 176, 233, 110, 240, 38, 155, 82, 163, 159, 22, 69,
+			234, 165, 198, 158, 34, 247, 170, 34, 226, 73, 50, 129, 38, 153,
+			159, 151, 54, 247, 228, 191, 75, 229, 23, 113, 42, 55, 131, 64,
+			102, 222, 42, 109, 239, 201, 124, 183, 206, 44, 66, 60, 78, 221,
+			53, 173, 30, 28, 0, 52, 205, 178, 57, 141, 54, 233, 3, 160,
+			3, 74, 107, 183, 78, 233, 80, 18, 71, 188, 185, 41, 159, 76,
+			147, 231, 229, 24, 183, 140, 165, 137, 94, 60, 39, 37, 3, 156,
+			122, 241, 116, 234, 154, 81, 71, 57, 236, 136, 120, 91, 232, 226,
+			30, 108, 42, 143, 205, 72, 196, 254, 101, 12, 148, 96, 225, 138,
+			194, 253, 122, 51, 144, 94, 0, 218, 93, 83, 220, 61, 18, 18,
+			76, 153, 117, 19, 57, 250, 180, 197, 94, 152, 68, 200, 68, 111,
+			206, 250, 13, 54, 213, 14, 162, 200, 219, 104, 238, 166, 223, 181,
+			210, 174, 30, 137, 228, 147, 138, 90, 44, 196, 62, 140, 90, 41,
+			46, 199, 73, 151, 9, 141, 174, 157, 109, 80, 29, 145, 190, 16,
+			107, 250, 32, 168, 152, 72, 248, 69, 141, 69, 212, 248, 181, 203,
+			35, 70, 169, 241, 5, 182, 202, 48, 13, 87, 85, 95, 52, 17,
+			39, 186, 152, 190, 183, 5, 109, 41, 132, 10, 159, 198, 72, 57,
+			53, 10, 236, 164, 240, 151, 126, 104, 0, 223, 195, 106, 135, 168,
+			161, 66, 195, 34, 64, 175, 30, 62, 198, 83, 146, 209, 201, 89,
+			43, 136, 208, 168, 16, 108, 220, 244, 130, 78, 164, 144, 171, 94,
+			148, 19, 99, 107, 20, 37, 94, 221, 45, 215, 243, 117, 152, 83,
+			21, 25, 55, 29, 212, 53, 61, 13, 221, 207, 33, 68, 245, 160,
+			205, 75, 194, 109, 23, 157, 20, 116, 216, 216, 125, 70, 221, 77,
+			170, 147, 145, 88, 222, 202, 233, 71, 92, 187, 192, 88, 165, 146,
+			170, 188, 56, 146, 130, 171, 204, 43, 104, 69, 244, 72, 146, 75,
+			106, 60, 156, 23, 133, 187, 82, 23, 45, 36, 8, 84, 58, 144,
+			112, 206, 130, 90, 54, 248, 150, 231, 35, 25, 73, 169, 171, 23,
+			51, 226, 90, 104, 180, 237, 134, 66, 181, 232, 9, 59, 172, 156,
+			120, 68, 184, 84, 44, 131, 131, 124, 84, 248, 154, 8, 159, 25,
+			119, 191, 17, 167, 135, 25, 5, 45, 21, 77, 178, 39, 39, 212,
+			172, 21, 187, 22, 119, 245, 27, 33, 162, 10, 80, 154, 184, 223,
+			112, 247, 89, 68, 172, 136, 111, 211, 22, 165, 6, 139, 76, 18,
+			31, 161, 116, 5, 135, 130, 145, 165, 162, 72, 235, 149, 217, 245,
+			178, 69, 18, 159, 49, 161, 104, 168, 40, 217, 48, 61, 241, 48,
+			151, 138, 15, 33, 253, 137, 161, 82, 25, 216, 204, 11, 81, 163,
+			12, 252, 196, 153, 80, 63, 139, 193, 102, 217, 124, 18, 178, 72,
+			188, 188, 130, 55, 0, 164, 220, 155, 90, 70, 82, 66, 109, 135,
+			193, 134, 187, 33, 156, 7, 27, 60, 242, 182, 124, 180, 131, 97,
+			220, 97, 52, 19, 178, 24, 215, 179, 194, 146, 178, 28, 136, 64,
+			26, 177, 235, 55, 74, 32, 20, 163, 19, 187, 112, 143, 13, 54,
+			83, 173, 212, 69, 96, 36, 38, 30, 198, 168, 7, 97, 35, 21,
+			180, 17, 239, 33, 72, 225, 120, 12, 229, 254, 15, 102, 76, 13,
+			102, 0, 148, 114, 255, 24, 202, 253, 31, 204, 140, 31, 81, 160,
+			5, 224, 113, 134, 6, 150, 49, 208, 34, 63, 148, 49, 159, 207,
+			8, 95, 216, 49, 84, 132, 62, 148, 33, 148, 188, 49, 75, 50,
+			0, 131, 132, 243, 211, 25, 187, 84, 248, 74, 38, 29, 129, 94,
+			198, 200, 118, 195, 88, 145, 235, 173, 100, 52, 117, 1, 88, 190,
+			69, 67, 244, 24, 49, 60, 118, 202, 197, 94, 218, 78, 83, 150,
+			80, 249, 52, 148, 96, 106, 50, 158, 23, 122, 108, 185, 130, 75,
+			130, 144, 40, 174, 219, 134, 65, 16, 239, 219, 3, 21, 114, 5,
+			56, 146, 140, 12, 23, 119, 5, 147, 87, 203, 56, 181, 136, 189,
+			8, 171, 199, 77, 31, 55, 191, 187, 113, 243, 187, 31, 119, 74,
+			146, 240, 230, 11, 130, 5, 55, 121, 89, 93, 165, 134, 121, 156,
+			186, 123, 154, 177, 179, 103, 177, 156, 186, 72, 87, 198, 81, 77,
+			221, 63, 173, 69, 134, 179, 103, 177, 74, 157, 1, 182, 222, 169,
+			233, 148, 76, 113, 246, 44, 187, 43, 241, 126, 83, 235, 119, 159,
+			33, 118, 53, 46, 46, 148, 167, 81, 120, 15, 246, 82, 111, 189,
+			189, 248, 233, 42, 252, 16, 187, 231, 34, 193, 213, 210, 219, 134,
+			168, 114, 79, 229, 231, 187, 43, 223, 239, 149, 9, 166, 110, 255,
+			157, 151, 85, 239, 251, 20, 5, 74, 30, 123, 170, 191, 107, 95,
+			121, 15, 243, 202, 219, 132, 9, 83, 16, 23, 180, 145, 44, 146,
+			183, 40, 245, 214, 26, 36, 111, 143, 73, 115, 70, 117, 19, 196,
+			87, 233, 60, 35, 136, 168, 233, 70, 177, 34, 198, 61, 147, 15,
+			51, 175, 73, 163, 103, 127, 238, 150, 233, 18, 38, 63, 165, 163,
+			4, 106, 230, 78, 212, 114, 17, 226, 156, 226, 87, 120, 209, 66,
+			29, 193, 181, 188, 122, 208, 12, 252, 105, 233, 206, 61, 38, 205,
+			15, 63, 157, 145, 166, 130, 49, 105, 126, 248, 233, 140, 124, 157,
+			104, 76, 154, 31, 126, 58, 51, 118, 48, 73, 176, 32, 161, 112,
+			56, 73, 200, 65, 194, 145, 51, 36, 79, 114, 50, 193, 132, 148,
+			163, 51, 228, 215, 77, 185, 216, 13, 106, 127, 18, 22, 251, 207,
+			155, 234, 234, 225, 54, 62, 69, 32, 20, 245, 120, 59, 228, 92,
+			4, 143, 239, 132, 90, 206, 186, 32, 227, 62, 55, 61, 31, 244,
+			2, 252, 93, 15, 154, 157, 150, 95, 34, 12, 182, 106, 248, 144,
+			8, 174, 165, 148, 227, 166, 27, 69, 157, 22, 111, 136, 109, 217,
+			141, 82, 21, 77, 151, 176, 168, 168, 71, 191, 115, 224, 134, 250,
+			246, 144, 231, 139, 64, 169, 98, 171, 16, 184, 199, 75, 59, 242,
+			62, 80, 125, 183, 204, 82, 174, 177, 80, 167, 160, 63, 81, 165,
+			62, 0, 129, 42, 159, 230, 97, 48, 43, 204, 250, 32, 121, 104,
+			215, 229, 221, 160, 35, 182, 137, 29, 121, 159, 221, 109, 52, 8,
+			187, 11, 111, 33, 1, 227, 146, 231, 32, 13, 47, 106, 55, 221,
+			93, 79, 61, 12, 217, 17, 183, 41, 21, 222, 13, 27, 145, 154,
+			204, 157, 225, 64, 66, 106, 238, 12, 68, 123, 106, 238, 12, 11,
+			18, 82, 115, 103, 228, 32, 33, 53, 119, 6, 204, 221, 39, 97,
+			238, 62, 50, 40, 231, 206, 164, 246, 151, 51, 246, 116, 225, 251,
+			6, 117, 68, 243, 21, 212, 56, 97, 99, 171, 250, 155, 65, 183,
+			201, 79, 199, 53, 77, 81, 172, 138, 127, 236, 98, 148, 167, 93,
+			80, 71, 91, 82, 150, 70, 121, 197, 75, 14, 127, 112, 211, 198,
+			39, 27, 5, 63, 77, 168, 94, 106, 237, 88, 64, 121, 223, 170,
+			119, 57, 20, 193, 167, 183, 118, 66, 216, 28, 134, 82, 149, 91,
+			32, 26, 185, 247, 182, 27, 248, 232, 46, 203, 235, 29, 188, 16,
+			6, 217, 34, 17, 193, 146, 249, 129, 160, 41, 34, 204, 79, 61,
+			165, 196, 54, 172, 243, 139, 67, 189, 88, 90, 154, 241, 125, 77,
+			173, 9, 96, 163, 208, 161, 38, 119, 65, 158, 88, 111, 112, 209,
+			239, 117, 221, 33, 172, 224, 6, 231, 109, 216, 252, 220, 173, 208,
+			109, 111, 99, 183, 117, 6, 36, 55, 209, 1, 162, 144, 53, 181,
+			209, 137, 81, 110, 170, 7, 190, 47, 220, 201, 227, 96, 90, 216,
+			184, 133, 43, 184, 90, 77, 101, 177, 37, 234, 186, 209, 199, 95,
+			89, 98, 55, 118, 69, 16, 158, 222, 193, 4, 9, 198, 146, 205,
+			84, 136, 10, 137, 182, 160, 47, 32, 47, 227, 229, 141, 237, 164,
+			136, 188, 95, 145, 14, 142, 114, 81, 127, 108, 185, 225, 13, 88,
+			39, 194, 2, 126, 246, 236, 180, 80, 171, 34, 124, 150, 146, 163,
+			252, 47, 5, 62, 33, 166, 42, 60, 148, 20, 14, 129, 30, 98,
+			25, 112, 9, 137, 198, 103, 110, 20, 243, 208, 139, 110, 36, 143,
+			221, 234, 234, 246, 114, 76, 84, 233, 48, 44, 39, 16, 70, 144,
+			220, 227, 19, 6, 130, 48, 138, 203, 132, 45, 241, 29, 196, 9,
+			82, 174, 188, 182, 153, 92, 249, 196, 119, 147, 196, 107, 28, 234,
+			237, 128, 174, 141, 5, 67, 132, 104, 59, 2, 238, 189, 243, 114,
+			248, 105, 194, 221, 12, 2, 212, 139, 111, 241, 121, 195, 13, 203,
+			251, 84, 187, 225, 134, 98, 247, 219, 111, 47, 219, 112, 159, 102,
+			15, 177, 187, 47, 190, 96, 181, 79, 171, 86, 231, 124, 41, 153,
+			3, 38, 246, 228, 121, 129, 58, 94, 215, 121, 74, 214, 241, 98,
+			53, 169, 156, 169, 119, 85, 59, 27, 77, 14, 233, 66, 60, 144,
+			21, 44, 200, 133, 161, 233, 68, 60, 129, 19, 110, 165, 30, 9,
+			5, 130, 87, 68, 16, 132, 44, 14, 93, 15, 47, 16, 40, 18,
+			145, 85, 137, 86, 153, 42, 159, 126, 167, 51, 20, 172, 104, 163,
+			233, 250, 55, 4, 209, 171, 213, 32, 47, 76, 10, 9, 16, 171,
+			1, 141, 162, 252, 226, 221, 75, 150, 22, 59, 95, 222, 119, 78,
+			68, 182, 135, 216, 189, 98, 86, 102, 216, 165, 52, 97, 107, 108,
+			161, 232, 54, 35, 34, 180, 227, 176, 217, 162, 28, 171, 34, 239,
+			72, 102, 81, 68, 46, 133, 141, 50, 155, 57, 251, 130, 53, 75,
+			45, 130, 205, 176, 173, 16, 227, 21, 202, 2, 61, 132, 37, 62,
+			178, 135, 216, 125, 122, 86, 164, 127, 2, 107, 244, 12, 63, 74,
+			109, 71, 24, 236, 35, 189, 29, 97, 184, 143, 140, 60, 201, 192,
+			4, 3, 18, 198, 79, 37, 9, 22, 36, 76, 78, 225, 73, 6,
+			36, 88, 212, 254, 147, 140, 61, 163, 51, 88, 54, 38, 36, 117,
+			90, 14, 36, 164, 234, 180, 12, 72, 24, 63, 157, 36, 96, 29,
+			83, 211, 186, 78, 155, 218, 95, 201, 216, 231, 117, 6, 91, 36,
+			36, 117, 218, 14, 36, 164, 234, 180, 13, 72, 24, 159, 77, 18,
+			44, 72, 56, 119, 23, 249, 146, 65, 76, 123, 156, 102, 254, 50,
+			211, 247, 189, 89, 163, 240, 187, 70, 234, 65, 55, 193, 21, 155,
+			66, 205, 218, 246, 218, 108, 131, 199, 59, 156, 251, 61, 215, 138,
+			132, 190, 29, 71, 189, 230, 105, 245, 94, 192, 92, 18, 126, 92,
+			239, 175, 233, 32, 34, 81, 20, 212, 61, 87, 159, 121, 233, 103,
+			82, 116, 43, 36, 109, 239, 78, 14, 203, 85, 184, 127, 148, 48,
+			49, 40, 59, 144, 88, 226, 37, 44, 11, 117, 133, 137, 16, 166,
+			233, 113, 131, 218, 127, 153, 201, 29, 34, 79, 17, 219, 30, 7,
+			189, 237, 235, 25, 243, 116, 225, 73, 54, 231, 179, 57, 237, 238,
+			162, 182, 160, 72, 168, 250, 104, 3, 0, 145, 148, 63, 133, 123,
+			70, 15, 18, 208, 36, 167, 246, 20, 162, 236, 32, 42, 158, 144,
+			191, 213, 253, 110, 5, 234, 144, 227, 40, 172, 126, 93, 233, 163,
+			227, 168, 143, 126, 61, 51, 112, 135, 2, 13, 0, 15, 48, 5,
+			90, 0, 158, 60, 133, 250, 232, 56, 232, 163, 223, 200, 152, 111,
+			203, 10, 125, 116, 28, 245, 209, 111, 100, 200, 56, 249, 167, 6,
+			201, 0, 12, 227, 122, 38, 107, 151, 10, 223, 153, 86, 71, 209,
+			71, 179, 123, 231, 235, 61, 86, 232, 122, 58, 57, 121, 118, 10,
+			253, 139, 133, 21, 29, 183, 101, 101, 36, 114, 123, 165, 167, 178,
+			178, 238, 151, 133, 253, 8, 169, 110, 92, 202, 230, 207, 100, 37,
+			161, 142, 75, 217, 252, 153, 172, 148, 239, 198, 165, 108, 254, 76,
+			86, 202, 119, 227, 82, 54, 127, 38, 43, 229, 187, 113, 41, 155,
+			63, 147, 149, 242, 221, 184, 146, 205, 159, 201, 30, 157, 33, 203,
+			114, 220, 6, 181, 223, 152, 181, 79, 21, 30, 238, 29, 55, 210,
+			0, 6, 104, 22, 186, 137, 20, 177, 246, 31, 127, 170, 223, 32,
+			151, 190, 49, 221, 111, 144, 75, 223, 152, 149, 11, 108, 92, 202,
+			165, 111, 204, 142, 31, 79, 18, 44, 72, 40, 158, 36, 59, 178,
+			83, 38, 181, 223, 156, 181, 143, 22, 182, 122, 59, 133, 146, 188,
+			216, 189, 55, 35, 142, 147, 178, 177, 27, 39, 239, 151, 118, 211,
+			153, 12, 74, 141, 203, 50, 241, 127, 79, 29, 32, 138, 115, 248,
+			84, 231, 129, 93, 188, 57, 221, 121, 224, 98, 111, 78, 35, 29,
+			16, 246, 230, 172, 124, 16, 118, 92, 114, 177, 55, 103, 15, 31,
+			33, 159, 81, 164, 100, 81, 251, 173, 89, 251, 112, 225, 87, 141,
+			61, 180, 36, 221, 202, 110, 167, 243, 242, 2, 229, 11, 116, 30,
+			107, 17, 113, 90, 184, 175, 224, 212, 73, 39, 240, 133, 182, 27,
+			197, 41, 53, 52, 228, 77, 126, 19, 31, 213, 222, 141, 57, 155,
+			146, 239, 181, 137, 208, 2, 74, 133, 196, 53, 251, 16, 86, 57,
+			43, 228, 165, 233, 20, 134, 240, 133, 215, 52, 134, 128, 39, 191,
+			53, 141, 33, 124, 229, 53, 43, 31, 180, 29, 151, 60, 249, 173,
+			217, 67, 5, 242, 225, 99, 228, 184, 136, 65, 126, 214, 109, 123,
+			103, 113, 161, 172, 171, 139, 227, 130, 144, 40, 145, 65, 202, 221,
+			182, 87, 144, 1, 203, 207, 170, 128, 229, 103, 19, 23, 10, 145,
+			123, 230, 95, 26, 100, 8, 53, 255, 75, 178, 22, 122, 140, 20,
+			46, 87, 43, 139, 11, 235, 151, 42, 143, 204, 189, 178, 186, 92,
+			91, 95, 91, 90, 185, 86, 153, 175, 94, 174, 86, 22, 242, 125,
+			116, 144, 228, 212, 235, 219, 121, 3, 160, 90, 229, 59, 214, 170,
+			181, 202, 66, 222, 164, 35, 100, 96, 121, 109, 245, 218, 218, 234,
+			250, 242, 210, 226, 245, 188, 69, 135, 9, 169, 46, 105, 216, 166,
+			67, 164, 191, 122, 245, 234, 218, 234, 220, 165, 197, 74, 222, 161,
+			148, 12, 175, 45, 45, 215, 22, 42, 181, 202, 194, 250, 98, 117,
+			101, 53, 159, 161, 119, 144, 209, 165, 229, 165, 245, 202, 213, 107,
+			171, 215, 215, 23, 42, 151, 231, 214, 22, 87, 243, 217, 11, 79,
+			144, 225, 238, 225, 210, 163, 229, 222, 112, 236, 56, 16, 233, 166,
+			49, 241, 190, 28, 179, 166, 134, 207, 31, 42, 39, 248, 40, 119,
+			141, 180, 54, 180, 153, 6, 47, 181, 201, 112, 61, 104, 165, 178,
+			95, 162, 93, 249, 209, 34, 114, 205, 120, 245, 156, 204, 177, 21,
+			52, 93, 127, 171, 28, 132, 91, 103, 183, 184, 143, 157, 56, 43,
+			62, 185, 109, 47, 194, 9, 74, 121, 51, 94, 76, 253, 254, 176,
+			105, 95, 153, 187, 86, 125, 244, 83, 5, 146, 161, 246, 112, 223,
+			117, 131, 252, 172, 77, 140, 65, 106, 13, 247, 209, 243, 63, 102,
+			179, 249, 160, 189, 27, 122, 91, 219, 49, 59, 127, 238, 174, 7,
+			164, 103, 33, 91, 92, 156, 39, 132, 45, 122, 117, 238, 71, 248,
+			112, 95, 67, 106, 121, 115, 109, 144, 42, 212, 151, 18, 123, 165,
+			8, 132, 194, 206, 151, 207, 177, 41, 52, 89, 203, 79, 197, 233,
+			139, 4, 181, 103, 245, 26, 97, 234, 113, 62, 60, 30, 169, 243,
+			54, 46, 43, 17, 223, 208, 245, 235, 60, 9, 185, 40, 235, 40,
+			19, 12, 177, 136, 47, 4, 111, 224, 150, 8, 58, 107, 91, 93,
+			213, 85, 217, 152, 27, 19, 97, 69, 219, 142, 227, 246, 133, 179,
+			103, 119, 118, 118, 202, 46, 118, 20, 81, 214, 20, 217, 162, 179,
+			139, 213, 249, 202, 210, 74, 101, 246, 124, 249, 28, 33, 108, 205,
+			199, 187, 140, 250, 158, 227, 198, 174, 122, 52, 15, 164, 221, 166,
+			187, 131, 182, 199, 173, 80, 70, 122, 242, 124, 21, 42, 164, 196,
+			162, 96, 51, 222, 65, 37, 168, 225, 129, 200, 184, 209, 137, 187,
+			176, 164, 58, 38, 31, 221, 86, 25, 2, 124, 107, 184, 56, 183,
+			194, 170, 43, 69, 118, 105, 110, 165, 186, 82, 34, 236, 177, 234,
+			234, 35, 203, 107, 171, 236, 177, 185, 90, 109, 110, 105, 181, 90,
+			89, 97, 203, 53, 54, 191, 188, 180, 80, 5, 218, 95, 97, 203,
+			151, 217, 220, 210, 117, 246, 138, 234, 210, 66, 73, 221, 235, 228,
+			79, 129, 30, 31, 161, 171, 34, 250, 237, 53, 82, 1, 63, 85,
+			243, 218, 115, 92, 197, 246, 215, 225, 176, 182, 130, 155, 60, 68,
+			213, 10, 3, 53, 68, 242, 217, 66, 191, 65, 24, 134, 44, 145,
+			22, 232, 61, 35, 42, 19, 146, 35, 134, 73, 173, 124, 223, 24,
+			233, 39, 166, 213, 71, 45, 218, 55, 3, 137, 57, 106, 141, 247,
+			189, 10, 18, 115, 3, 226, 167, 72, 188, 163, 175, 136, 137, 68,
+			252, 20, 137, 7, 250, 238, 198, 68, 249, 83, 36, 30, 236, 155,
+			196, 68, 67, 252, 20, 137, 19, 178, 248, 41, 245, 211, 200, 82,
+			187, 208, 55, 101, 144, 223, 182, 136, 153, 237, 163, 214, 164, 121,
+			161, 240, 105, 139, 205, 73, 19, 124, 202, 218, 167, 199, 173, 66,
+			26, 200, 176, 23, 83, 106, 206, 75, 234, 65, 93, 144, 205, 74,
+			248, 16, 203, 52, 58, 93, 169, 117, 222, 21, 31, 178, 91, 117,
+			236, 49, 69, 178, 215, 76, 165, 86, 127, 55, 255, 152, 102, 15,
+			49, 197, 186, 94, 139, 42, 197, 74, 236, 198, 242, 89, 143, 219,
+			41, 156, 226, 116, 162, 124, 47, 59, 90, 232, 72, 203, 75, 28,
+			55, 161, 66, 177, 28, 94, 164, 214, 132, 93, 238, 95, 233, 170,
+			215, 226, 81, 236, 182, 218, 64, 109, 94, 200, 215, 99, 79, 140,
+			245, 182, 106, 79, 245, 185, 36, 109, 220, 47, 210, 29, 197, 173,
+			95, 123, 145, 16, 66, 172, 108, 159, 73, 173, 66, 246, 164, 248,
+			109, 195, 68, 203, 244, 12, 181, 38, 7, 100, 186, 65, 173, 201,
+			83, 231, 197, 111, 139, 90, 147, 247, 62, 72, 254, 200, 36, 166,
+			211, 71, 237, 115, 125, 215, 141, 194, 255, 109, 226, 69, 109, 191,
+			225, 213, 49, 34, 149, 100, 29, 233, 16, 40, 174, 124, 142, 92,
+			80, 201, 212, 102, 250, 228, 74, 152, 114, 82, 114, 162, 102, 23,
+			40, 160, 191, 174, 195, 163, 88, 220, 0, 22, 117, 184, 145, 34,
+			41, 12, 75, 33, 149, 49, 23, 228, 134, 118, 39, 158, 86, 183,
+			217, 103, 102, 212, 57, 218, 204, 76, 58, 30, 179, 238, 150, 162,
+			193, 122, 208, 100, 242, 233, 66, 121, 90, 126, 17, 148, 94, 225,
+			69, 41, 92, 234, 162, 238, 146, 160, 180, 0, 199, 148, 81, 20,
+			182, 131, 29, 54, 119, 173, 138, 46, 28, 64, 175, 219, 174, 223,
+			104, 106, 177, 81, 5, 164, 67, 23, 244, 85, 125, 157, 106, 102,
+			166, 229, 238, 206, 204, 176, 144, 215, 185, 119, 147, 99, 128, 206,
+			238, 199, 231, 245, 249, 19, 33, 150, 3, 147, 112, 206, 161, 228,
+			97, 98, 59, 32, 157, 91, 231, 205, 19, 133, 243, 108, 62, 240,
+			111, 130, 4, 36, 140, 8, 210, 127, 25, 177, 139, 119, 245, 82,
+			55, 136, 113, 99, 64, 247, 84, 199, 65, 89, 217, 58, 111, 30,
+			81, 144, 73, 173, 243, 199, 25, 249, 23, 6, 214, 110, 80, 235,
+			126, 115, 164, 240, 78, 67, 70, 206, 145, 118, 90, 133, 10, 101,
+			226, 119, 35, 173, 43, 151, 9, 123, 12, 35, 44, 96, 60, 6,
+			17, 238, 96, 63, 244, 186, 33, 103, 137, 133, 90, 248, 208, 138,
+			179, 103, 201, 71, 100, 52, 17, 198, 91, 237, 109, 55, 242, 240,
+			13, 230, 212, 19, 137, 186, 255, 6, 246, 81, 67, 38, 181, 238,
+			31, 26, 38, 63, 39, 250, 111, 82, 235, 101, 230, 72, 225, 227,
+			160, 143, 238, 233, 178, 34, 46, 29, 244, 64, 144, 45, 79, 197,
+			240, 16, 121, 103, 102, 90, 157, 8, 104, 103, 131, 39, 209, 140,
+			221, 168, 235, 52, 78, 82, 103, 73, 216, 188, 55, 93, 175, 137,
+			175, 178, 6, 172, 17, 176, 72, 62, 125, 36, 143, 132, 125, 198,
+			195, 16, 24, 99, 71, 190, 15, 250, 68, 117, 233, 149, 115, 139,
+			213, 133, 245, 185, 218, 149, 181, 171, 149, 165, 213, 39, 166, 245,
+			240, 96, 10, 94, 166, 135, 135, 3, 26, 26, 38, 223, 16, 195,
+			179, 168, 117, 201, 164, 133, 255, 190, 239, 240, 82, 204, 246, 69,
+			71, 152, 14, 211, 140, 75, 45, 106, 7, 176, 137, 151, 100, 188,
+			151, 122, 179, 163, 238, 50, 144, 244, 193, 177, 28, 117, 215, 195,
+			114, 66, 50, 145, 207, 98, 11, 204, 169, 3, 90, 47, 22, 187,
+			30, 166, 226, 122, 20, 79, 186, 37, 56, 113, 69, 44, 199, 8,
+			3, 36, 165, 98, 213, 77, 70, 242, 213, 165, 58, 79, 112, 99,
+			25, 48, 254, 33, 5, 153, 212, 186, 148, 31, 37, 31, 16, 184,
+			177, 169, 117, 197, 28, 45, 188, 125, 95, 220, 32, 127, 248, 22,
+			81, 163, 184, 144, 60, 228, 38, 61, 39, 226, 186, 20, 172, 182,
+			180, 101, 84, 91, 69, 69, 119, 109, 3, 58, 56, 168, 32, 147,
+			90, 87, 70, 242, 228, 7, 69, 231, 29, 106, 45, 154, 249, 194,
+			187, 246, 239, 124, 171, 213, 137, 65, 108, 122, 209, 190, 171, 21,
+			197, 97, 172, 117, 222, 61, 103, 113, 32, 221, 191, 153, 75, 208,
+			163, 27, 180, 214, 196, 46, 34, 195, 254, 8, 118, 217, 16, 198,
+			65, 60, 127, 208, 35, 112, 12, 232, 229, 128, 130, 76, 106, 45,
+			14, 143, 144, 119, 154, 56, 130, 12, 181, 106, 230, 29, 133, 55,
+			153, 122, 4, 146, 185, 79, 41, 11, 249, 116, 42, 124, 167, 207,
+			58, 62, 134, 9, 225, 13, 214, 244, 208, 194, 124, 171, 145, 169,
+			219, 127, 208, 71, 245, 248, 111, 202, 26, 161, 67, 249, 64, 53,
+			40, 93, 184, 254, 46, 115, 195, 13, 47, 14, 221, 112, 151, 137,
+			104, 36, 37, 22, 186, 221, 134, 109, 25, 164, 68, 60, 255, 18,
+			106, 101, 30, 24, 191, 126, 53, 140, 205, 233, 40, 60, 77, 249,
+			248, 0, 180, 50, 25, 201, 210, 40, 58, 135, 105, 236, 69, 114,
+			158, 36, 142, 50, 6, 96, 37, 175, 32, 147, 90, 181, 177, 113,
+			242, 143, 4, 198, 178, 212, 122, 149, 57, 81, 120, 222, 232, 198,
+			88, 42, 186, 134, 112, 135, 143, 228, 99, 134, 226, 100, 66, 113,
+			122, 25, 13, 109, 83, 69, 57, 189, 5, 6, 101, 64, 24, 28,
+			163, 28, 151, 84, 192, 177, 54, 245, 8, 72, 66, 38, 192, 208,
+			82, 205, 137, 12, 137, 129, 85, 248, 231, 11, 85, 27, 43, 77,
+			135, 140, 114, 119, 144, 203, 111, 162, 219, 50, 217, 83, 139, 120,
+			69, 89, 152, 25, 4, 70, 178, 6, 224, 96, 76, 65, 38, 181,
+			94, 117, 224, 224, 70, 70, 132, 241, 35, 255, 180, 74, 14, 165,
+			148, 100, 69, 178, 223, 138, 122, 92, 252, 168, 69, 104, 77, 86,
+			144, 156, 141, 83, 74, 236, 120, 183, 205, 39, 12, 102, 76, 245,
+			215, 240, 55, 157, 32, 217, 182, 27, 199, 60, 244, 39, 76, 102,
+			77, 245, 215, 20, 72, 143, 18, 2, 2, 169, 136, 183, 59, 97,
+			97, 153, 126, 72, 65, 245, 146, 254, 61, 146, 221, 246, 162, 56,
+			8, 119, 39, 108, 102, 76, 13, 159, 191, 51, 173, 178, 238, 109,
+			189, 252, 136, 200, 93, 83, 197, 232, 1, 146, 105, 55, 59, 161,
+			219, 156, 112, 176, 114, 9, 209, 2, 201, 169, 104, 236, 19, 25,
+			252, 162, 97, 122, 129, 56, 81, 188, 219, 228, 19, 4, 213, 228,
+			83, 47, 210, 230, 10, 228, 173, 137, 34, 197, 215, 144, 172, 236,
+			3, 61, 72, 198, 30, 169, 174, 172, 46, 215, 174, 247, 152, 9,
+			142, 146, 67, 203, 181, 234, 149, 234, 210, 220, 226, 226, 245, 245,
+			149, 234, 210, 149, 197, 202, 250, 181, 185, 213, 213, 74, 109, 41,
+			111, 208, 9, 50, 126, 121, 109, 117, 173, 86, 89, 191, 186, 182,
+			184, 90, 213, 95, 204, 226, 3, 196, 193, 198, 232, 29, 100, 116,
+			101, 245, 250, 98, 165, 167, 226, 9, 50, 190, 80, 153, 95, 156,
+			171, 205, 173, 86, 95, 89, 89, 191, 92, 171, 86, 150, 22, 22,
+			175, 231, 141, 226, 101, 50, 170, 122, 94, 83, 86, 218, 125, 167,
+			234, 40, 33, 245, 109, 175, 217, 64, 239, 138, 9, 83, 76, 8,
+			166, 172, 238, 182, 249, 133, 38, 161, 138, 104, 214, 181, 185, 247,
+			197, 204, 13, 31, 204, 49, 99, 106, 224, 252, 209, 253, 240, 168,
+			123, 83, 27, 13, 123, 147, 46, 248, 100, 76, 183, 150, 184, 191,
+			208, 35, 251, 52, 215, 228, 170, 181, 247, 231, 152, 53, 53, 112,
+			254, 216, 11, 207, 90, 77, 143, 99, 33, 113, 213, 253, 251, 36,
+			167, 82, 233, 241, 61, 141, 200, 103, 0, 82, 237, 24, 183, 209,
+			142, 174, 241, 146, 191, 199, 136, 50, 164, 242, 127, 251, 236, 39,
+			95, 53, 12, 101, 66, 249, 231, 115, 36, 75, 157, 225, 190, 63,
+			54, 254, 167, 13, 229, 127, 218, 80, 94, 58, 27, 202, 113, 180,
+			119, 28, 23, 63, 149, 13, 69, 27, 86, 238, 208, 134, 149, 3,
+			137, 97, 229, 128, 54, 172, 28, 236, 43, 43, 195, 10, 252, 84,
+			54, 20, 109, 88, 153, 208, 134, 149, 67, 137, 97, 229, 144, 54,
+			172, 28, 238, 59, 110, 144, 215, 8, 187, 202, 49, 243, 193, 194,
+			53, 80, 158, 147, 229, 32, 118, 236, 134, 62, 72, 115, 181, 148,
+			150, 28, 86, 149, 88, 196, 57, 97, 175, 217, 195, 145, 94, 251,
+			154, 215, 150, 19, 197, 254, 176, 82, 236, 51, 208, 210, 49, 173,
+			204, 31, 59, 126, 151, 86, 230, 143, 221, 243, 128, 236, 215, 137,
+			190, 211, 6, 121, 3, 62, 241, 104, 157, 50, 23, 10, 225, 109,
+			247, 43, 225, 115, 137, 147, 116, 183, 124, 76, 148, 145, 231, 34,
+			244, 60, 233, 120, 194, 116, 146, 158, 195, 180, 157, 200, 22, 197,
+			111, 27, 250, 34, 211, 51, 212, 58, 53, 48, 37, 126, 67, 31,
+			167, 95, 38, 126, 91, 212, 58, 245, 240, 188, 28, 197, 100, 223,
+			25, 131, 188, 150, 160, 24, 49, 99, 158, 43, 212, 190, 133, 81,
+			244, 162, 119, 191, 94, 130, 78, 54, 153, 61, 45, 126, 103, 160,
+			173, 227, 226, 183, 65, 173, 25, 38, 122, 9, 154, 218, 204, 153,
+			179, 228, 247, 6, 137, 105, 247, 81, 231, 239, 247, 253, 59, 195,
+			40, 252, 250, 32, 155, 99, 17, 94, 106, 254, 255, 216, 251, 23,
+			240, 184, 146, 234, 80, 20, 86, 85, 237, 222, 221, 42, 189, 75,
+			15, 75, 91, 178, 93, 150, 199, 150, 52, 163, 135, 37, 143, 103,
+			198, 158, 25, 152, 182, 212, 182, 219, 35, 75, 154, 238, 150, 141,
+			103, 0, 121, 91, 189, 37, 53, 211, 234, 22, 189, 91, 150, 53,
+			142, 255, 143, 159, 71, 8, 39, 132, 203, 51, 9, 9, 132, 71,
+			72, 66, 128, 156, 132, 4, 66, 30, 132, 215, 201, 16, 194, 227,
+			144, 144, 192, 33, 33, 9, 36, 247, 230, 112, 51, 55, 220, 240,
+			37, 39, 92, 238, 220, 19, 238, 253, 106, 213, 99, 239, 221, 106,
+			201, 246, 12, 220, 124, 223, 189, 119, 62, 248, 172, 85, 93, 123,
+			213, 170, 85, 85, 171, 86, 85, 173, 71, 196, 7, 114, 37, 76,
+			140, 138, 128, 202, 119, 146, 160, 59, 19, 37, 94, 191, 137, 14,
+			171, 117, 119, 117, 91, 197, 6, 21, 104, 105, 112, 215, 22, 226,
+			68, 185, 2, 178, 78, 155, 118, 43, 5, 123, 185, 92, 90, 41,
+			172, 142, 200, 131, 142, 121, 94, 245, 229, 153, 71, 55, 55, 228,
+			115, 127, 121, 205, 91, 119, 71, 213, 41, 56, 68, 181, 57, 34,
+			113, 165, 48, 5, 103, 101, 85, 11, 76, 3, 131, 187, 190, 83,
+			52, 154, 106, 33, 87, 222, 40, 44, 7, 41, 22, 38, 38, 120,
+			58, 164, 205, 70, 146, 132, 172, 72, 99, 148, 0, 181, 164, 106,
+			60, 244, 237, 12, 152, 64, 153, 7, 233, 16, 161, 230, 154, 69,
+			230, 227, 41, 175, 240, 27, 138, 9, 55, 39, 110, 60, 89, 40,
+			229, 111, 134, 241, 156, 41, 87, 248, 163, 155, 87, 189, 74, 201,
+			171, 66, 212, 75, 137, 73, 153, 69, 42, 28, 5, 159, 223, 112,
+			55, 10, 28, 12, 177, 119, 96, 81, 30, 188, 225, 27, 58, 141,
+			102, 132, 63, 28, 78, 42, 33, 200, 59, 197, 7, 55, 54, 175,
+			250, 155, 87, 199, 131, 93, 116, 124, 185, 188, 62, 1, 252, 25,
+			12, 42, 43, 54, 139, 250, 149, 50, 248, 209, 78, 220, 80, 127,
+			221, 156, 168, 138, 202, 254, 196, 13, 248, 247, 166, 249, 234, 166,
+			201, 42, 33, 19, 51, 212, 153, 102, 151, 221, 245, 162, 154, 13,
+			82, 189, 7, 183, 33, 233, 124, 164, 115, 81, 40, 30, 168, 92,
+			25, 99, 119, 66, 247, 29, 81, 77, 121, 182, 188, 238, 85, 11,
+			235, 30, 196, 72, 84, 205, 74, 127, 131, 192, 29, 66, 98, 20,
+			35, 98, 76, 11, 67, 70, 46, 219, 50, 203, 78, 177, 112, 205,
+			83, 251, 69, 232, 203, 138, 178, 222, 216, 109, 78, 206, 150, 87,
+			107, 50, 127, 220, 241, 96, 22, 203, 171, 171, 133, 210, 106, 45,
+			87, 52, 230, 219, 29, 208, 98, 121, 213, 159, 184, 81, 44, 175,
+			222, 172, 251, 197, 74, 185, 152, 247, 42, 254, 196, 13, 249, 199,
+			45, 235, 151, 43, 171, 110, 73, 133, 208, 240, 39, 110, 132, 193,
+			91, 126, 123, 181, 0, 145, 74, 147, 203, 203, 229, 205, 146, 32,
+			82, 21, 44, 185, 178, 164, 46, 130, 31, 233, 204, 27, 186, 5,
+			147, 135, 110, 107, 246, 213, 161, 250, 206, 24, 252, 220, 217, 251,
+			92, 153, 43, 182, 27, 75, 108, 237, 47, 78, 56, 212, 165, 150,
+			213, 96, 53, 48, 107, 9, 63, 73, 156, 172, 124, 181, 1, 230,
+			134, 114, 65, 200, 195, 38, 120, 28, 149, 43, 234, 170, 121, 76,
+			48, 26, 140, 163, 225, 209, 68, 7, 235, 53, 82, 83, 81, 39,
+			15, 238, 162, 9, 196, 200, 82, 188, 149, 30, 166, 182, 128, 112,
+			3, 35, 174, 53, 224, 116, 193, 192, 14, 110, 150, 124, 175, 58,
+			104, 2, 235, 181, 210, 184, 172, 132, 68, 173, 125, 1, 140, 25,
+			113, 157, 126, 186, 173, 144, 32, 70, 60, 107, 208, 121, 153, 74,
+			24, 163, 154, 14, 110, 102, 248, 154, 155, 87, 79, 249, 114, 123,
+			17, 219, 77, 209, 221, 44, 129, 37, 150, 235, 67, 70, 153, 81,
+			169, 35, 134, 19, 138, 43, 233, 160, 66, 174, 228, 243, 16, 117,
+			91, 94, 108, 105, 82, 16, 180, 189, 63, 128, 49, 35, 30, 63,
+			36, 45, 25, 26, 32, 0, 7, 121, 153, 181, 223, 249, 2, 138,
+			18, 183, 230, 250, 97, 138, 228, 109, 26, 156, 66, 22, 210, 188,
+			188, 85, 146, 122, 48, 88, 3, 73, 235, 102, 112, 71, 161, 170,
+			125, 62, 172, 45, 249, 100, 128, 221, 107, 94, 69, 6, 195, 221,
+			245, 192, 45, 183, 216, 141, 138, 119, 77, 218, 247, 129, 78, 3,
+			38, 157, 87, 61, 120, 47, 51, 65, 124, 225, 254, 47, 176, 240,
+			219, 33, 43, 199, 71, 130, 222, 11, 206, 191, 204, 234, 13, 96,
+			209, 219, 254, 1, 250, 56, 76, 39, 196, 172, 18, 190, 65, 156,
+			89, 158, 228, 43, 69, 119, 181, 54, 60, 133, 209, 221, 225, 78,
+			65, 95, 251, 25, 22, 45, 23, 221, 194, 186, 47, 205, 248, 74,
+			50, 28, 112, 217, 204, 35, 193, 247, 82, 188, 153, 30, 7, 54,
+			35, 49, 143, 54, 44, 199, 185, 75, 222, 47, 149, 204, 211, 128,
+			154, 75, 209, 176, 168, 146, 92, 8, 18, 79, 54, 172, 238, 0,
+			198, 140, 108, 244, 246, 209, 191, 196, 10, 43, 98, 228, 41, 107,
+			191, 243, 121, 44, 175, 200, 12, 105, 33, 143, 94, 229, 208, 59,
+			104, 236, 166, 175, 121, 99, 43, 149, 130, 87, 202, 23, 183, 7,
+			197, 246, 48, 83, 231, 135, 208, 134, 164, 3, 14, 131, 183, 145,
+			204, 95, 39, 93, 23, 85, 248, 97, 53, 45, 35, 225, 196, 117,
+			200, 202, 229, 242, 250, 250, 102, 73, 235, 57, 101, 233, 15, 28,
+			186, 12, 12, 116, 28, 149, 204, 221, 205, 75, 63, 155, 50, 175,
+			71, 174, 154, 113, 82, 242, 212, 188, 63, 105, 167, 78, 21, 255,
+			67, 204, 210, 34, 184, 96, 241, 97, 249, 239, 184, 91, 216, 24,
+			207, 123, 215, 70, 34, 153, 64, 130, 197, 4, 17, 140, 253, 16,
+			243, 17, 48, 183, 55, 128, 49, 35, 79, 245, 15, 208, 127, 36,
+			98, 242, 224, 6, 102, 189, 22, 97, 230, 252, 53, 137, 46, 28,
+			153, 47, 33, 93, 53, 140, 171, 85, 201, 168, 209, 201, 150, 132,
+			214, 120, 115, 226, 134, 185, 92, 17, 223, 46, 73, 5, 11, 144,
+			94, 217, 249, 203, 21, 141, 151, 6, 105, 12, 225, 233, 78, 189,
+			67, 152, 204, 51, 42, 213, 76, 40, 192, 191, 81, 4, 248, 21,
+			33, 39, 221, 85, 175, 118, 79, 57, 189, 185, 252, 164, 87, 189,
+			162, 182, 177, 72, 32, 135, 157, 148, 200, 38, 165, 203, 179, 170,
+			34, 201, 81, 231, 99, 72, 9, 49, 241, 68, 114, 236, 113, 119,
+			236, 169, 151, 60, 225, 142, 61, 149, 28, 123, 252, 216, 216, 201,
+			151, 220, 51, 1, 252, 81, 70, 76, 210, 113, 67, 230, 7, 44,
+			241, 205, 141, 13, 175, 194, 151, 93, 31, 238, 235, 43, 238, 178,
+			24, 65, 57, 193, 100, 117, 161, 249, 44, 184, 254, 178, 43, 83,
+			78, 15, 47, 138, 15, 76, 10, 234, 17, 201, 183, 117, 247, 122,
+			97, 125, 115, 221, 248, 130, 173, 208, 0, 155, 47, 163, 168, 170,
+			136, 88, 213, 221, 184, 92, 240, 249, 228, 177, 99, 202, 48, 81,
+			26, 230, 189, 22, 225, 132, 6, 145, 0, 27, 91, 52, 72, 4,
+			216, 222, 65, 159, 142, 193, 220, 64, 204, 122, 7, 194, 7, 156,
+			223, 142, 153, 160, 0, 227, 106, 150, 20, 101, 202, 177, 200, 193,
+			33, 16, 251, 53, 166, 159, 145, 69, 66, 213, 212, 18, 120, 102,
+			230, 178, 58, 165, 180, 126, 91, 218, 44, 22, 107, 176, 74, 142,
+			149, 134, 170, 145, 183, 72, 149, 171, 86, 103, 139, 131, 160, 42,
+			178, 245, 218, 241, 148, 217, 194, 76, 110, 147, 98, 97, 85, 249,
+			138, 170, 96, 86, 5, 117, 12, 150, 245, 180, 30, 147, 243, 214,
+			55, 138, 210, 62, 33, 171, 130, 187, 220, 224, 131, 19, 131, 6,
+			186, 201, 149, 186, 164, 11, 30, 230, 179, 233, 92, 42, 147, 156,
+			229, 63, 198, 47, 186, 149, 2, 220, 1, 169, 58, 6, 126, 152,
+			15, 222, 24, 52, 21, 7, 111, 14, 242, 7, 119, 152, 86, 8,
+			165, 233, 182, 15, 12, 245, 171, 62, 89, 42, 111, 21, 189, 252,
+			170, 119, 218, 245, 61, 127, 226, 134, 129, 151, 174, 186, 190, 7,
+			106, 123, 78, 133, 23, 82, 206, 160, 133, 18, 191, 90, 113, 133,
+			164, 12, 110, 7, 180, 37, 95, 122, 198, 15, 210, 1, 5, 194,
+			185, 36, 117, 144, 181, 130, 87, 113, 43, 203, 107, 219, 176, 28,
+			10, 190, 18, 111, 240, 68, 229, 86, 71, 121, 97, 101, 231, 230,
+			38, 253, 152, 116, 246, 115, 26, 24, 157, 26, 138, 148, 43, 62,
+			248, 92, 232, 30, 14, 142, 132, 220, 206, 4, 81, 161, 12, 156,
+			114, 90, 65, 38, 103, 253, 12, 161, 39, 61, 178, 96, 30, 27,
+			48, 38, 64, 21, 200, 67, 154, 120, 190, 3, 177, 62, 13, 18,
+			1, 14, 236, 167, 175, 69, 176, 6, 48, 179, 222, 141, 112, 175,
+			179, 93, 179, 4, 84, 10, 208, 82, 205, 225, 85, 93, 97, 128,
+			33, 78, 237, 201, 182, 4, 100, 170, 236, 64, 233, 21, 94, 150,
+			249, 128, 70, 131, 184, 121, 202, 25, 76, 109, 117, 162, 254, 160,
+			233, 5, 142, 1, 37, 122, 233, 66, 214, 42, 212, 216, 169, 65,
+			34, 192, 158, 125, 244, 139, 22, 144, 77, 152, 245, 97, 132, 123,
+			156, 63, 176, 106, 232, 190, 109, 229, 178, 158, 110, 89, 123, 6,
+			155, 152, 0, 156, 105, 208, 3, 170, 102, 201, 232, 163, 89, 72,
+			51, 4, 235, 111, 127, 115, 99, 163, 12, 121, 129, 2, 97, 160,
+			208, 200, 176, 207, 210, 80, 60, 164, 137, 107, 117, 10, 134, 31,
+			156, 105, 35, 234, 97, 228, 32, 88, 75, 197, 115, 63, 15, 230,
+			139, 27, 181, 91, 74, 13, 242, 58, 135, 174, 160, 100, 239, 163,
+			69, 33, 138, 201, 159, 184, 161, 74, 150, 170, 170, 168, 254, 145,
+			174, 206, 42, 191, 51, 84, 234, 13, 235, 212, 238, 202, 107, 157,
+			131, 160, 156, 93, 196, 134, 233, 212, 168, 65, 36, 64, 170, 87,
+			16, 129, 201, 214, 213, 77, 255, 86, 170, 20, 22, 179, 126, 31,
+			225, 46, 231, 43, 82, 165, 144, 79, 100, 114, 61, 111, 250, 129,
+			83, 82, 84, 202, 195, 56, 155, 139, 100, 57, 31, 70, 101, 10,
+			74, 215, 167, 124, 72, 119, 127, 200, 236, 119, 209, 239, 203, 43,
+			65, 157, 128, 69, 67, 193, 141, 151, 193, 77, 131, 15, 150, 139,
+			229, 205, 188, 198, 179, 238, 150, 220, 85, 175, 82, 59, 242, 26,
+			233, 248, 170, 87, 29, 210, 34, 46, 144, 58, 203, 66, 143, 223,
+			48, 182, 17, 87, 100, 111, 175, 4, 54, 25, 79, 62, 224, 243,
+			233, 204, 12, 236, 92, 20, 158, 23, 252, 83, 19, 19, 79, 154,
+			251, 170, 241, 66, 121, 34, 95, 94, 246, 39, 170, 174, 255, 164,
+			63, 33, 243, 42, 140, 5, 191, 143, 185, 27, 133, 9, 25, 242,
+			110, 204, 168, 179, 181, 5, 99, 161, 40, 27, 19, 33, 141, 210,
+			112, 31, 244, 250, 176, 253, 22, 100, 35, 129, 216, 140, 224, 250,
+			87, 45, 151, 2, 101, 25, 130, 230, 5, 180, 42, 141, 115, 98,
+			242, 196, 125, 122, 66, 88, 49, 24, 99, 45, 140, 196, 17, 228,
+			247, 81, 99, 155, 6, 137, 0, 89, 39, 253, 223, 165, 12, 141,
+			49, 235, 83, 66, 24, 61, 35, 15, 103, 74, 208, 71, 249, 166,
+			181, 192, 127, 95, 206, 241, 172, 156, 112, 193, 150, 179, 83, 189,
+			218, 101, 158, 44, 200, 15, 174, 232, 251, 97, 201, 138, 152, 236,
+			187, 102, 84, 12, 9, 176, 81, 175, 156, 24, 17, 96, 87, 55,
+			253, 136, 100, 148, 205, 172, 167, 17, 30, 112, 126, 9, 113, 120,
+			153, 133, 227, 220, 176, 63, 162, 136, 8, 105, 81, 198, 33, 88,
+			155, 19, 236, 56, 214, 69, 54, 226, 240, 217, 78, 27, 252, 81,
+			121, 30, 28, 223, 145, 92, 82, 157, 19, 69, 219, 42, 153, 123,
+			112, 188, 8, 197, 91, 50, 125, 180, 45, 32, 219, 128, 208, 11,
+			21, 114, 11, 76, 44, 172, 167, 145, 202, 254, 216, 0, 129, 178,
+			158, 70, 125, 253, 180, 64, 197, 180, 177, 63, 143, 26, 190, 131,
+			144, 243, 4, 159, 49, 87, 198, 210, 77, 101, 175, 135, 1, 29,
+			247, 72, 39, 205, 172, 6, 122, 1, 21, 234, 183, 56, 54, 133,
+			212, 128, 38, 74, 32, 197, 217, 231, 81, 162, 143, 190, 70, 108,
+			144, 144, 225, 236, 155, 226, 220, 243, 189, 58, 231, 158, 192, 182,
+			69, 95, 232, 231, 141, 193, 135, 246, 73, 218, 227, 70, 50, 187,
+			121, 53, 184, 211, 49, 187, 139, 162, 25, 148, 55, 254, 48, 159,
+			138, 26, 144, 238, 124, 6, 127, 238, 247, 206, 55, 95, 18, 186,
+			200, 155, 95, 94, 118, 125, 99, 33, 227, 134, 236, 144, 76, 75,
+			224, 144, 106, 236, 113, 12, 223, 120, 186, 164, 163, 7, 251, 222,
+			40, 149, 105, 129, 117, 138, 61, 152, 43, 110, 81, 29, 174, 238,
+			86, 50, 189, 80, 169, 243, 34, 182, 7, 167, 206, 122, 213, 180,
+			187, 190, 80, 46, 22, 150, 183, 51, 202, 10, 170, 150, 97, 6,
+			225, 115, 230, 217, 221, 117, 57, 35, 166, 35, 146, 65, 12, 245,
+			250, 148, 233, 236, 190, 169, 15, 68, 72, 6, 48, 20, 7, 162,
+			239, 98, 152, 52, 136, 89, 255, 40, 148, 193, 191, 197, 117, 38,
+			13, 188, 18, 129, 109, 3, 95, 46, 23, 117, 50, 229, 221, 166,
+			18, 13, 207, 165, 240, 217, 127, 101, 179, 168, 210, 30, 201, 249,
+			175, 50, 49, 93, 145, 55, 225, 87, 66, 147, 158, 154, 136, 42,
+			38, 6, 217, 245, 144, 110, 101, 30, 170, 118, 187, 56, 47, 248,
+			85, 117, 251, 90, 240, 252, 221, 216, 47, 155, 221, 105, 165, 125,
+			75, 230, 7, 118, 31, 119, 112, 195, 126, 179, 118, 124, 132, 178,
+			254, 143, 193, 248, 32, 24, 1, 165, 245, 34, 80, 214, 255, 17,
+			133, 44, 146, 254, 104, 210, 184, 237, 24, 83, 163, 170, 182, 227,
+			86, 118, 73, 109, 53, 150, 24, 131, 15, 210, 70, 99, 235, 205,
+			122, 105, 220, 247, 150, 203, 165, 188, 15, 134, 44, 36, 163, 65,
+			214, 69, 99, 37, 183, 84, 246, 193, 140, 37, 150, 145, 192, 233,
+			215, 32, 218, 25, 178, 195, 208, 72, 79, 183, 26, 148, 218, 26,
+			99, 106, 167, 53, 70, 64, 227, 246, 134, 231, 195, 169, 173, 20,
+			208, 187, 113, 21, 204, 47, 200, 217, 133, 211, 31, 192, 7, 164,
+			89, 197, 248, 130, 182, 31, 185, 228, 21, 139, 143, 138, 15, 114,
+			226, 219, 243, 79, 79, 208, 56, 139, 29, 104, 120, 51, 66, 244,
+			139, 205, 96, 158, 113, 160, 129, 77, 125, 186, 217, 132, 163, 214,
+			177, 168, 249, 88, 16, 64, 59, 239, 86, 93, 25, 226, 71, 89,
+			97, 75, 233, 78, 35, 54, 29, 199, 140, 77, 71, 186, 180, 60,
+			206, 121, 82, 28, 209, 197, 111, 190, 9, 203, 61, 30, 236, 207,
+			121, 239, 154, 87, 44, 111, 120, 21, 95, 243, 68, 105, 81, 64,
+			196, 152, 50, 55, 158, 128, 7, 84, 99, 217, 0, 1, 161, 74,
+			121, 253, 234, 169, 38, 176, 40, 185, 90, 40, 9, 169, 36, 232,
+			210, 193, 14, 202, 21, 253, 150, 77, 249, 122, 57, 15, 70, 208,
+			234, 176, 80, 241, 66, 121, 86, 141, 149, 104, 96, 132, 105, 2,
+			219, 137, 33, 149, 155, 190, 244, 230, 95, 247, 170, 122, 125, 220,
+			93, 67, 152, 95, 27, 185, 16, 46, 24, 42, 94, 85, 231, 214,
+			147, 129, 87, 150, 53, 199, 40, 47, 149, 171, 144, 169, 0, 164,
+			103, 177, 224, 87, 101, 120, 132, 160, 69, 165, 147, 6, 228, 228,
+			11, 62, 92, 195, 154, 67, 205, 78, 34, 196, 1, 61, 224, 133,
+			38, 66, 69, 218, 13, 232, 160, 1, 33, 207, 139, 14, 29, 124,
+			183, 38, 70, 162, 91, 202, 79, 148, 43, 42, 8, 193, 186, 56,
+			133, 21, 220, 98, 200, 32, 87, 219, 223, 132, 204, 90, 116, 112,
+			63, 209, 169, 57, 245, 6, 94, 13, 69, 235, 13, 207, 173, 82,
+			57, 248, 205, 87, 206, 182, 160, 99, 75, 84, 229, 138, 49, 74,
+			135, 147, 4, 92, 129, 230, 203, 112, 41, 15, 230, 147, 235, 229,
+			170, 167, 162, 15, 87, 125, 158, 247, 42, 133, 107, 42, 164, 130,
+			138, 93, 163, 205, 110, 140, 53, 132, 81, 122, 54, 42, 5, 49,
+			177, 42, 98, 238, 148, 66, 199, 5, 184, 99, 58, 151, 206, 242,
+			236, 252, 153, 220, 165, 100, 38, 197, 211, 89, 190, 144, 153, 191,
+			152, 158, 73, 205, 240, 211, 151, 121, 238, 92, 138, 79, 207, 47,
+			92, 206, 164, 207, 158, 203, 241, 115, 243, 179, 51, 169, 76, 150,
+			39, 231, 102, 248, 244, 252, 92, 46, 147, 62, 189, 152, 155, 207,
+			100, 169, 49, 218, 17, 191, 36, 231, 46, 243, 212, 139, 22, 50,
+			169, 44, 88, 234, 164, 47, 44, 204, 166, 83, 51, 33, 251, 157,
+			81, 158, 158, 155, 158, 93, 156, 73, 207, 157, 29, 229, 167, 23,
+			115, 124, 110, 62, 71, 249, 108, 250, 66, 58, 151, 154, 225, 185,
+			249, 81, 104, 118, 231, 119, 124, 254, 12, 191, 144, 202, 76, 159,
+			75, 206, 229, 146, 167, 211, 179, 233, 220, 101, 104, 240, 76, 58,
+			55, 39, 26, 59, 51, 159, 161, 60, 201, 23, 146, 153, 92, 122,
+			122, 113, 54, 153, 225, 11, 139, 153, 133, 249, 108, 138, 139, 158,
+			205, 164, 179, 211, 179, 201, 244, 133, 212, 204, 56, 79, 207, 241,
+			185, 121, 158, 186, 152, 154, 203, 241, 236, 185, 228, 236, 108, 180,
+			163, 148, 207, 95, 154, 75, 101, 148, 157, 145, 233, 38, 63, 157,
+			226, 179, 233, 228, 233, 217, 148, 104, 10, 250, 57, 147, 206, 164,
+			166, 115, 162, 67, 193, 95, 211, 233, 153, 212, 92, 46, 57, 59,
+			74, 57, 88, 28, 38, 103, 71, 121, 234, 69, 169, 11, 11, 179,
+			201, 204, 229, 81, 133, 52, 155, 122, 108, 49, 53, 151, 75, 39,
+			103, 249, 76, 242, 66, 242, 108, 42, 203, 135, 111, 197, 149, 133,
+			204, 252, 244, 98, 38, 117, 65, 80, 61, 127, 134, 103, 23, 79,
+			103, 115, 233, 220, 98, 46, 197, 207, 206, 207, 207, 0, 179, 179,
+			169, 204, 197, 244, 116, 42, 251, 32, 159, 157, 207, 2, 195, 22,
+			179, 169, 81, 202, 103, 146, 185, 36, 52, 189, 144, 153, 63, 147,
+			206, 101, 31, 20, 127, 159, 94, 204, 166, 129, 113, 233, 185, 92,
+			42, 147, 89, 4, 127, 204, 17, 126, 110, 254, 82, 234, 98, 42,
+			195, 167, 147, 139, 217, 212, 12, 112, 120, 126, 78, 244, 86, 204,
+			149, 212, 124, 230, 178, 64, 43, 248, 0, 35, 48, 202, 47, 157,
+			75, 229, 206, 165, 50, 130, 169, 192, 173, 164, 96, 67, 54, 151,
+			73, 79, 231, 194, 213, 230, 51, 60, 55, 159, 201, 209, 80, 63,
+			249, 92, 234, 236, 108, 250, 108, 106, 110, 58, 37, 126, 158, 23,
+			104, 46, 165, 179, 169, 17, 158, 204, 164, 197, 241, 93, 224, 20,
+			108, 190, 148, 188, 204, 231, 23, 161, 215, 98, 160, 22, 179, 41,
+			42, 255, 14, 77, 221, 81, 24, 79, 158, 62, 195, 147, 51, 23,
+			211, 130, 114, 85, 123, 97, 62, 155, 77, 171, 233, 2, 108, 155,
+			62, 167, 120, 110, 204, 181, 120, 67, 175, 50, 157, 26, 108, 120,
+			16, 76, 167, 142, 200, 63, 101, 225, 225, 192, 112, 235, 176, 49,
+			220, 186, 171, 33, 173, 13, 183, 196, 159, 178, 240, 72, 195, 168,
+			54, 199, 18, 127, 202, 194, 163, 13, 19, 218, 112, 75, 252, 41,
+			11, 135, 2, 19, 175, 33, 99, 226, 53, 220, 112, 72, 27, 110,
+			137, 63, 255, 102, 63, 24, 240, 216, 175, 69, 98, 235, 115, 190,
+			188, 159, 39, 121, 224, 185, 21, 137, 247, 35, 211, 79, 11, 177,
+			86, 88, 151, 81, 96, 85, 46, 103, 157, 86, 21, 202, 159, 130,
+			96, 204, 50, 248, 95, 17, 210, 152, 122, 165, 188, 91, 25, 13,
+			165, 58, 7, 19, 170, 77, 249, 157, 210, 14, 164, 147, 73, 197,
+			93, 14, 118, 12, 253, 131, 216, 16, 132, 170, 0, 176, 140, 212,
+			46, 133, 162, 186, 130, 221, 148, 129, 103, 204, 157, 186, 138, 56,
+			176, 81, 22, 39, 209, 42, 95, 204, 77, 243, 245, 66, 190, 4,
+			18, 29, 210, 83, 187, 165, 77, 177, 13, 76, 142, 242, 201, 147,
+			247, 31, 27, 213, 130, 122, 163, 82, 46, 122, 27, 213, 194, 50,
+			63, 91, 241, 86, 203, 149, 130, 91, 50, 212, 171, 203, 111, 25,
+			79, 81, 89, 12, 213, 169, 117, 213, 93, 126, 114, 203, 173, 200,
+			200, 187, 219, 158, 91, 225, 229, 18, 168, 144, 98, 203, 95, 47,
+			148, 54, 171, 42, 192, 204, 125, 199, 76, 255, 138, 229, 210, 234,
+			56, 159, 245, 220, 141, 160, 203, 21, 143, 15, 250, 235, 158, 91,
+			241, 242, 131, 28, 60, 183, 93, 177, 27, 113, 161, 250, 81, 85,
+			141, 131, 37, 190, 76, 108, 10, 249, 113, 33, 17, 132, 142, 54,
+			168, 54, 116, 153, 119, 195, 229, 79, 76, 221, 59, 182, 86, 222,
+			148, 225, 90, 220, 10, 229, 128, 253, 37, 195, 123, 43, 29, 98,
+			60, 39, 160, 230, 136, 126, 41, 168, 128, 150, 83, 240, 229, 155,
+			236, 177, 99, 199, 38, 199, 224, 127, 185, 99, 199, 78, 193, 255,
+			30, 23, 93, 63, 121, 242, 228, 201, 177, 201, 169, 177, 227, 147,
+			185, 169, 227, 167, 78, 156, 60, 117, 226, 228, 248, 73, 253, 223,
+			227, 227, 252, 244, 54, 60, 189, 195, 43, 162, 9, 186, 40, 78,
+			161, 2, 251, 40, 223, 242, 184, 87, 242, 193, 221, 8, 210, 63,
+			123, 144, 189, 64, 101, 69, 151, 227, 171, 226, 252, 60, 145, 57,
+			51, 77, 249, 241, 227, 199, 79, 6, 125, 217, 218, 218, 26, 47,
+			120, 213, 21, 208, 16, 43, 43, 203, 226, 255, 162, 198, 120, 245,
+			122, 117, 68, 104, 108, 94, 40, 117, 54, 63, 108, 30, 15, 2,
+			77, 159, 79, 158, 226, 211, 229, 245, 141, 205, 170, 23, 90, 11,
+			208, 224, 194, 124, 54, 253, 34, 126, 69, 112, 102, 120, 228, 202,
+			184, 126, 236, 48, 149, 140, 242, 169, 20, 241, 64, 121, 246, 189,
+			234, 146, 26, 224, 97, 248, 124, 110, 113, 118, 118, 100, 164, 110,
+			61, 152, 239, 195, 199, 70, 66, 111, 27, 124, 234, 86, 52, 173,
+			122, 85, 129, 165, 188, 146, 119, 183, 67, 180, 249, 213, 202, 230,
+			114, 21, 26, 184, 230, 22, 121, 245, 154, 106, 49, 82, 253, 104,
+			245, 218, 40, 7, 130, 30, 124, 174, 93, 186, 54, 94, 189, 38,
+			160, 189, 122, 36, 43, 109, 250, 222, 50, 191, 155, 79, 30, 59,
+			22, 237, 225, 241, 93, 123, 120, 169, 80, 58, 62, 197, 175, 156,
+			245, 170, 89, 8, 83, 33, 126, 78, 250, 103, 10, 69, 47, 23,
+			29, 136, 51, 233, 217, 84, 46, 125, 33, 197, 87, 170, 138, 140,
+			221, 190, 57, 186, 82, 213, 148, 46, 166, 231, 114, 247, 221, 203,
+			171, 133, 229, 39, 125, 254, 48, 31, 30, 30, 150, 37, 35, 43,
+			213, 241, 252, 214, 185, 194, 234, 218, 140, 91, 133, 175, 70, 248,
+			67, 15, 241, 227, 83, 35, 252, 199, 56, 252, 54, 91, 222, 210,
+			63, 61, 24, 92, 233, 39, 5, 189, 249, 242, 150, 15, 40, 213,
+			19, 98, 72, 134, 249, 227, 166, 130, 148, 82, 147, 247, 237, 92,
+			70, 6, 155, 248, 124, 242, 190, 123, 239, 189, 247, 254, 227, 247,
+			29, 11, 196, 134, 138, 11, 182, 88, 42, 92, 215, 88, 78, 222,
+			127, 172, 22, 203, 248, 115, 27, 204, 97, 217, 127, 62, 60, 44,
+			153, 50, 1, 131, 37, 254, 27, 225, 99, 97, 114, 110, 49, 131,
+			5, 30, 193, 46, 141, 231, 72, 8, 15, 76, 128, 145, 200, 4,
+			184, 119, 215, 9, 112, 222, 189, 230, 242, 43, 114, 32, 199, 85,
+			192, 51, 81, 229, 66, 161, 88, 44, 248, 161, 9, 0, 217, 133,
+			214, 161, 148, 63, 204, 119, 255, 96, 143, 105, 206, 31, 14, 74,
+			199, 75, 222, 214, 233, 205, 66, 49, 239, 85, 134, 71, 68, 199,
+			178, 138, 67, 170, 9, 201, 152, 145, 224, 212, 46, 234, 204, 201,
+			190, 23, 74, 85, 209, 115, 85, 83, 118, 93, 117, 27, 56, 48,
+			50, 14, 25, 70, 128, 150, 128, 7, 39, 110, 193, 131, 116, 201,
+			175, 186, 165, 234, 120, 169, 188, 21, 234, 182, 42, 229, 165, 242,
+			22, 127, 152, 71, 234, 236, 217, 211, 128, 240, 91, 119, 185, 84,
+			222, 26, 95, 245, 170, 41, 49, 217, 100, 217, 240, 72, 168, 231,
+			209, 222, 171, 202, 2, 24, 222, 165, 167, 247, 237, 218, 83, 29,
+			209, 78, 233, 25, 124, 97, 187, 186, 38, 15, 18, 145, 137, 22,
+			30, 168, 225, 145, 218, 89, 120, 214, 171, 78, 7, 227, 62, 60,
+			2, 178, 254, 124, 118, 126, 142, 95, 144, 137, 83, 41, 229, 233,
+			146, 44, 145, 167, 118, 105, 239, 26, 226, 19, 88, 210, 250, 81,
+			197, 69, 93, 235, 232, 199, 91, 177, 1, 221, 209, 254, 35, 155,
+			26, 15, 82, 116, 75, 15, 84, 99, 100, 59, 120, 67, 232, 13,
+			55, 199, 110, 172, 151, 75, 213, 181, 155, 99, 55, 242, 238, 246,
+			205, 220, 13, 177, 121, 223, 60, 117, 99, 189, 80, 186, 121, 234,
+			134, 239, 45, 223, 124, 98, 252, 134, 80, 151, 196, 146, 189, 249,
+			146, 199, 7, 169, 10, 77, 43, 191, 134, 199, 80, 25, 125, 83,
+			89, 97, 120, 121, 165, 11, 64, 224, 205, 124, 97, 181, 80, 245,
+			85, 230, 120, 213, 210, 40, 135, 166, 70, 41, 151, 141, 141, 114,
+			104, 77, 190, 32, 66, 147, 65, 164, 203, 13, 249, 138, 40, 182,
+			237, 173, 178, 198, 230, 185, 203, 107, 234, 109, 87, 233, 113, 66,
+			255, 83, 34, 69, 155, 15, 136, 141, 124, 181, 204, 55, 55, 64,
+			77, 208, 159, 202, 8, 238, 178, 112, 178, 190, 182, 55, 50, 74,
+			35, 46, 214, 178, 165, 193, 199, 7, 185, 191, 185, 178, 82, 184,
+			30, 241, 11, 244, 96, 30, 128, 38, 58, 60, 184, 152, 155, 30,
+			28, 121, 48, 82, 26, 113, 197, 31, 231, 73, 121, 101, 126, 92,
+			78, 6, 31, 206, 228, 133, 167, 188, 138, 54, 54, 81, 172, 220,
+			244, 61, 208, 38, 135, 221, 192, 11, 49, 207, 175, 110, 83, 65,
+			198, 136, 12, 123, 190, 81, 41, 148, 204, 157, 99, 205, 84, 146,
+			81, 170, 195, 77, 233, 36, 81, 58, 142, 15, 229, 160, 211, 9,
+			13, 103, 25, 94, 119, 32, 200, 181, 104, 83, 124, 171, 34, 23,
+			170, 62, 248, 59, 232, 128, 128, 240, 16, 25, 104, 100, 71, 178,
+			133, 193, 169, 99, 147, 247, 139, 221, 97, 242, 68, 238, 216, 228,
+			169, 227, 199, 78, 77, 158, 24, 63, 54, 249, 248, 160, 154, 221,
+			62, 7, 216, 108, 47, 50, 152, 16, 212, 132, 246, 203, 165, 64,
+			111, 62, 49, 202, 5, 182, 113, 181, 128, 220, 107, 110, 22, 174,
+			235, 71, 101, 24, 201, 144, 170, 230, 242, 25, 120, 16, 135, 216,
+			75, 82, 203, 51, 177, 178, 130, 156, 112, 148, 67, 48, 107, 183,
+			146, 167, 252, 137, 106, 57, 157, 157, 207, 194, 34, 27, 30, 169,
+			163, 160, 142, 175, 151, 159, 42, 20, 139, 46, 172, 46, 175, 52,
+			182, 152, 149, 111, 88, 151, 188, 171, 19, 1, 41, 19, 198, 109,
+			100, 226, 108, 177, 124, 213, 45, 46, 205, 203, 60, 76, 19, 130,
+			160, 137, 80, 35, 35, 58, 175, 26, 220, 215, 75, 73, 51, 10,
+			235, 92, 146, 196, 175, 8, 141, 81, 48, 125, 92, 255, 113, 69,
+			119, 72, 116, 85, 230, 224, 23, 189, 85, 89, 51, 118, 116, 145,
+			242, 39, 174, 248, 213, 202, 10, 124, 26, 234, 81, 121, 217, 31,
+			223, 144, 146, 77, 244, 101, 106, 162, 88, 184, 90, 113, 43, 219,
+			160, 118, 143, 175, 85, 215, 139, 135, 225, 47, 253, 237, 8, 220,
+			185, 80, 51, 145, 117, 35, 254, 134, 183, 204, 135, 142, 92, 30,
+			59, 178, 62, 118, 36, 159, 59, 114, 238, 212, 145, 11, 167, 142,
+			100, 199, 143, 172, 60, 62, 52, 206, 103, 11, 79, 122, 91, 5,
+			223, 131, 99, 142, 96, 80, 48, 74, 155, 190, 39, 177, 157, 47,
+			231, 93, 152, 172, 67, 62, 127, 226, 74, 58, 59, 175, 149, 154,
+			51, 82, 88, 229, 21, 56, 60, 114, 229, 37, 195, 52, 236, 226,
+			245, 178, 114, 94, 142, 132, 248, 99, 12, 206, 11, 238, 70, 1,
+			6, 68, 151, 202, 83, 132, 164, 117, 98, 39, 110, 232, 167, 110,
+			224, 200, 212, 204, 145, 169, 25, 202, 193, 56, 207, 248, 155, 233,
+			216, 106, 21, 190, 236, 110, 192, 2, 41, 175, 132, 35, 200, 25,
+			153, 175, 194, 75, 24, 254, 131, 63, 75, 147, 52, 31, 182, 94,
+			139, 18, 29, 244, 157, 72, 27, 237, 253, 36, 194, 93, 206, 27,
+			16, 207, 4, 39, 92, 61, 247, 203, 43, 48, 229, 129, 199, 126,
+			161, 180, 28, 214, 178, 104, 125, 53, 139, 95, 80, 86, 126, 123,
+			29, 139, 104, 189, 115, 209, 227, 210, 82, 207, 47, 92, 243, 34,
+			86, 102, 63, 137, 112, 60, 100, 101, 246, 147, 40, 209, 22, 178,
+			50, 251, 73, 196, 58, 101, 78, 110, 176, 50, 123, 19, 194, 204,
+			249, 26, 226, 115, 229, 210, 88, 201, 91, 149, 231, 224, 200, 105,
+			218, 213, 167, 70, 113, 144, 172, 127, 154, 158, 83, 31, 154, 3,
+			166, 10, 217, 1, 87, 146, 1, 50, 184, 56, 245, 171, 38, 41,
+			66, 41, 220, 38, 160, 86, 31, 42, 227, 93, 121, 64, 95, 41,
+			87, 196, 193, 88, 223, 30, 212, 50, 76, 29, 26, 71, 213, 255,
+			105, 29, 166, 160, 24, 244, 51, 30, 50, 59, 122, 19, 74, 180,
+			132, 204, 142, 222, 132, 218, 59, 204, 75, 198, 59, 222, 140, 104,
+			102, 181, 60, 190, 188, 86, 41, 175, 23, 54, 215, 165, 27, 226,
+			230, 114, 97, 194, 45, 185, 197, 109, 191, 224, 79, 232, 236, 151,
+			19, 87, 55, 87, 253, 137, 245, 114, 169, 92, 113, 11, 69, 49,
+			129, 151, 164, 143, 103, 193, 247, 55, 189, 37, 157, 243, 77, 190,
+			126, 52, 233, 122, 227, 215, 142, 59, 183, 10, 113, 230, 236, 238,
+			222, 237, 220, 234, 157, 101, 240, 245, 9, 26, 87, 161, 88, 25,
+			163, 86, 201, 93, 55, 190, 193, 226, 111, 118, 146, 198, 192, 222,
+			8, 222, 83, 90, 167, 14, 140, 135, 40, 27, 79, 11, 210, 167,
+			101, 88, 94, 136, 22, 116, 154, 124, 43, 73, 50, 242, 11, 54,
+			166, 92, 141, 9, 124, 217, 23, 249, 82, 53, 57, 158, 219, 222,
+			240, 2, 135, 113, 21, 225, 23, 252, 190, 27, 51, 26, 100, 15,
+			208, 70, 21, 32, 212, 171, 72, 151, 238, 211, 206, 179, 201, 46,
+			202, 220, 141, 194, 248, 114, 229, 234, 230, 42, 92, 36, 44, 250,
+			94, 69, 52, 31, 84, 102, 143, 208, 38, 25, 172, 1, 194, 2,
+			129, 211, 119, 211, 148, 179, 195, 203, 215, 108, 161, 146, 126, 42,
+			191, 17, 165, 108, 148, 182, 21, 74, 87, 203, 155, 165, 252, 146,
+			122, 146, 235, 141, 3, 5, 80, 179, 85, 253, 166, 220, 132, 217,
+			67, 52, 1, 193, 86, 174, 185, 197, 222, 4, 84, 227, 207, 38,
+			247, 211, 254, 40, 161, 73, 85, 229, 162, 152, 193, 25, 243, 5,
+			155, 166, 212, 93, 247, 74, 121, 8, 200, 208, 219, 168, 92, 154,
+			235, 177, 45, 169, 171, 41, 130, 131, 207, 216, 25, 218, 36, 3,
+			180, 74, 44, 20, 176, 28, 172, 143, 197, 212, 147, 104, 194, 31,
+			58, 255, 13, 81, 26, 84, 96, 14, 77, 172, 20, 138, 94, 104,
+			126, 24, 152, 221, 123, 71, 115, 68, 79, 15, 70, 45, 191, 240,
+			148, 156, 30, 86, 6, 254, 102, 251, 41, 93, 247, 242, 5, 87,
+			122, 162, 203, 105, 208, 8, 37, 98, 162, 176, 195, 180, 165, 186,
+			182, 185, 126, 181, 228, 22, 138, 75, 155, 149, 130, 242, 239, 111,
+			54, 133, 139, 149, 2, 235, 163, 137, 107, 5, 111, 11, 126, 151,
+			94, 254, 113, 1, 139, 159, 14, 209, 230, 124, 121, 171, 84, 44,
+			187, 121, 248, 25, 70, 50, 211, 164, 203, 22, 43, 5, 167, 74,
+			27, 13, 115, 5, 57, 114, 193, 133, 186, 221, 8, 37, 115, 162,
+			223, 247, 80, 86, 242, 182, 150, 202, 149, 165, 188, 87, 172, 186,
+			75, 32, 145, 148, 255, 124, 91, 201, 219, 154, 175, 204, 136, 114,
+			24, 102, 214, 79, 27, 203, 197, 188, 170, 35, 131, 30, 36, 202,
+			197, 60, 252, 56, 120, 130, 90, 208, 193, 54, 218, 20, 245, 238,
+			111, 162, 241, 233, 249, 11, 23, 82, 115, 185, 118, 36, 126, 157,
+			73, 101, 167, 51, 105, 184, 223, 110, 199, 167, 22, 158, 73, 94,
+			160, 221, 209, 201, 165, 23, 243, 189, 245, 76, 209, 196, 88, 248,
+			19, 55, 224, 223, 155, 19, 58, 234, 238, 196, 13, 245, 215, 205,
+			193, 95, 69, 148, 130, 31, 191, 36, 122, 146, 198, 100, 148, 6,
+			232, 251, 233, 254, 103, 147, 189, 180, 39, 218, 158, 202, 147, 176,
+			146, 145, 53, 89, 23, 141, 133, 249, 32, 1, 246, 16, 165, 240,
+			206, 3, 55, 134, 74, 34, 236, 139, 204, 147, 25, 243, 179, 154,
+			211, 65, 125, 129, 115, 99, 205, 245, 245, 140, 144, 192, 224, 219,
+			90, 105, 12, 102, 87, 93, 193, 213, 75, 227, 254, 230, 250, 186,
+			91, 217, 86, 148, 104, 48, 16, 105, 228, 142, 69, 218, 131, 212,
+			22, 127, 108, 250, 64, 73, 237, 234, 132, 111, 199, 179, 80, 1,
+			248, 39, 190, 197, 25, 245, 9, 187, 143, 38, 42, 30, 152, 86,
+			222, 142, 20, 51, 117, 217, 20, 141, 129, 15, 140, 18, 95, 3,
+			117, 218, 20, 31, 73, 105, 34, 171, 178, 251, 105, 98, 121, 121,
+			105, 211, 247, 42, 126, 111, 28, 68, 192, 222, 159, 197, 151, 151,
+			5, 224, 179, 19, 212, 134, 148, 15, 126, 175, 12, 169, 176, 191,
+			206, 103, 179, 162, 130, 252, 78, 85, 102, 73, 74, 3, 51, 101,
+			37, 186, 14, 213, 249, 116, 90, 87, 146, 159, 135, 62, 98, 167,
+			104, 179, 92, 108, 114, 99, 87, 146, 43, 58, 73, 130, 169, 153,
+			105, 90, 49, 127, 251, 236, 28, 237, 94, 247, 42, 171, 94, 126,
+			169, 80, 170, 150, 151, 228, 150, 90, 241, 86, 122, 155, 128, 101,
+			221, 59, 41, 201, 120, 43, 25, 38, 191, 73, 151, 170, 101, 93,
+			38, 48, 65, 128, 120, 47, 191, 84, 46, 5, 136, 252, 222, 102,
+			32, 103, 55, 76, 234, 155, 249, 146, 46, 242, 89, 138, 118, 66,
+			105, 161, 180, 26, 198, 211, 178, 23, 158, 14, 253, 69, 128, 166,
+			102, 11, 107, 189, 243, 45, 236, 5, 148, 66, 62, 49, 137, 160,
+			237, 246, 16, 52, 194, 39, 240, 253, 35, 180, 9, 30, 240, 183,
+			37, 130, 246, 219, 164, 64, 126, 3, 24, 178, 180, 219, 12, 244,
+			82, 24, 87, 199, 237, 225, 234, 52, 95, 95, 8, 144, 94, 160,
+			76, 46, 172, 8, 70, 118, 123, 24, 219, 229, 167, 33, 116, 143,
+			210, 14, 88, 58, 17, 108, 157, 183, 135, 173, 13, 190, 12, 33,
+			27, 167, 237, 193, 94, 186, 4, 202, 105, 111, 23, 71, 195, 45,
+			170, 126, 240, 227, 180, 248, 141, 13, 82, 234, 87, 221, 138, 170,
+			217, 29, 212, 108, 20, 197, 178, 78, 63, 181, 65, 238, 249, 189,
+			61, 156, 104, 5, 68, 21, 57, 63, 133, 104, 107, 116, 109, 177,
+			135, 65, 107, 146, 37, 74, 126, 31, 124, 54, 57, 64, 157, 29,
+			251, 133, 172, 35, 100, 120, 240, 69, 141, 196, 198, 119, 38, 177,
+			157, 43, 148, 6, 82, 66, 200, 111, 144, 19, 74, 64, 75, 224,
+			121, 182, 176, 76, 155, 66, 146, 150, 245, 24, 201, 44, 219, 208,
+			66, 247, 249, 53, 114, 141, 54, 26, 25, 201, 238, 161, 150, 16,
+			168, 138, 151, 251, 118, 145, 221, 25, 168, 244, 252, 218, 61, 245,
+			224, 51, 201, 7, 104, 103, 20, 185, 220, 234, 14, 221, 114, 91,
+			31, 188, 155, 182, 67, 93, 127, 182, 224, 87, 167, 33, 141, 140,
+			96, 143, 76, 40, 163, 217, 35, 161, 65, 151, 38, 140, 232, 27,
+			167, 49, 192, 160, 58, 216, 251, 108, 178, 187, 46, 13, 25, 89,
+			141, 29, 161, 173, 222, 245, 234, 146, 137, 32, 93, 81, 27, 109,
+			139, 119, 189, 106, 226, 84, 87, 6, 255, 32, 70, 91, 34, 26,
+			111, 221, 237, 122, 134, 54, 107, 61, 120, 41, 239, 173, 72, 84,
+			167, 15, 61, 155, 236, 167, 125, 245, 181, 231, 25, 111, 69, 42,
+			173, 1, 204, 78, 208, 70, 9, 138, 125, 143, 192, 50, 217, 117,
+			156, 130, 154, 44, 25, 217, 214, 91, 167, 70, 34, 3, 21, 33,
+			222, 64, 114, 242, 153, 121, 118, 138, 38, 124, 175, 42, 165, 70,
+			236, 246, 164, 70, 220, 247, 224, 214, 152, 77, 81, 219, 247, 170,
+			85, 181, 195, 239, 173, 22, 168, 154, 172, 79, 171, 68, 161, 211,
+			136, 44, 217, 177, 145, 38, 110, 127, 35, 29, 252, 48, 162, 173,
+			209, 30, 178, 131, 180, 63, 185, 176, 144, 153, 191, 152, 156, 93,
+			202, 230, 146, 185, 197, 236, 210, 14, 29, 117, 110, 62, 183, 148,
+			77, 9, 29, 181, 157, 54, 207, 165, 82, 51, 217, 165, 76, 234,
+			98, 58, 117, 169, 29, 51, 155, 226, 185, 100, 59, 97, 93, 180,
+			93, 150, 45, 101, 82, 143, 45, 166, 178, 185, 212, 76, 187, 197,
+			24, 109, 85, 165, 217, 92, 50, 35, 202, 98, 172, 133, 54, 10,
+			28, 75, 233, 185, 51, 243, 237, 54, 107, 166, 9, 73, 64, 106,
+			166, 61, 14, 13, 204, 231, 150, 76, 73, 226, 212, 75, 158, 73,
+			62, 190, 231, 17, 139, 61, 120, 107, 93, 216, 13, 127, 224, 79,
+			220, 208, 240, 205, 187, 31, 161, 52, 88, 170, 204, 161, 61, 51,
+			169, 76, 250, 98, 82, 168, 224, 59, 67, 129, 167, 94, 180, 48,
+			155, 158, 78, 11, 78, 36, 168, 149, 89, 156, 77, 181, 227, 187,
+			47, 208, 142, 29, 106, 165, 12, 231, 149, 204, 213, 134, 243, 162,
+			212, 78, 78, 231, 210, 23, 83, 237, 72, 48, 118, 38, 53, 155,
+			202, 65, 44, 241, 4, 181, 178, 11, 201, 11, 237, 228, 244, 35,
+			143, 191, 224, 249, 93, 57, 156, 255, 155, 13, 26, 103, 177, 68,
+			195, 95, 97, 68, 191, 137, 193, 12, 49, 209, 192, 166, 254, 10,
+			69, 44, 10, 167, 142, 193, 237, 247, 180, 106, 136, 39, 55, 171,
+			107, 229, 138, 63, 190, 139, 105, 225, 162, 239, 153, 188, 254, 145,
+			20, 194, 190, 138, 66, 164, 174, 143, 249, 233, 236, 204, 24, 24,
+			175, 83, 174, 34, 54, 169, 59, 28, 121, 209, 185, 34, 14, 212,
+			218, 92, 66, 69, 114, 146, 177, 164, 202, 21, 176, 211, 216, 219,
+			156, 160, 188, 225, 149, 198, 100, 251, 65, 64, 168, 171, 126, 158,
+			78, 93, 211, 233, 20, 138, 65, 232, 19, 189, 54, 165, 9, 189,
+			156, 19, 112, 13, 46, 195, 228, 231, 249, 213, 77, 95, 84, 244,
+			169, 206, 177, 63, 202, 189, 241, 213, 241, 81, 101, 225, 43, 151,
+			216, 104, 40, 157, 16, 220, 191, 7, 231, 104, 99, 153, 67, 27,
+			186, 148, 109, 76, 115, 195, 162, 182, 183, 81, 127, 146, 6, 70,
+			90, 27, 70, 224, 79, 196, 72, 91, 195, 97, 248, 19, 194, 47,
+			141, 208, 95, 195, 96, 60, 99, 245, 54, 188, 12, 57, 239, 198,
+			225, 203, 68, 55, 72, 202, 2, 41, 139, 182, 195, 94, 157, 210,
+			88, 212, 87, 166, 42, 82, 255, 166, 84, 39, 222, 241, 5, 191,
+			85, 196, 193, 105, 149, 101, 170, 92, 225, 139, 27, 121, 87, 122,
+			5, 86, 202, 155, 171, 107, 193, 13, 181, 188, 191, 6, 43, 104,
+			143, 134, 3, 39, 135, 124, 0, 124, 240, 247, 12, 188, 29, 84,
+			32, 91, 105, 95, 1, 175, 15, 50, 7, 110, 40, 32, 44, 213,
+			78, 212, 16, 6, 17, 252, 93, 10, 58, 199, 186, 106, 82, 70,
+			28, 88, 119, 243, 30, 119, 175, 185, 133, 162, 138, 225, 57, 231,
+			93, 175, 6, 5, 188, 234, 174, 158, 226, 147, 147, 227, 65, 40,
+			135, 222, 68, 27, 125, 80, 135, 114, 24, 192, 135, 137, 51, 22,
+			164, 27, 14, 82, 97, 237, 130, 234, 100, 56, 72, 195, 64, 188,
+			137, 78, 7, 65, 26, 14, 88, 93, 206, 189, 128, 75, 199, 145,
+			212, 195, 32, 189, 102, 23, 193, 129, 106, 197, 188, 237, 41, 31,
+			194, 154, 32, 14, 7, 172, 182, 72, 16, 135, 3, 172, 147, 30,
+			11, 130, 56, 112, 171, 221, 57, 196, 147, 193, 8, 232, 54, 100,
+			116, 177, 130, 26, 207, 112, 236, 5, 110, 53, 69, 98, 47, 240,
+			214, 54, 122, 46, 8, 189, 48, 104, 117, 57, 39, 121, 210, 32,
+			170, 137, 64, 80, 242, 182, 34, 129, 46, 180, 79, 77, 109, 75,
+			130, 184, 193, 16, 237, 128, 154, 117, 210, 25, 193, 107, 210, 192,
+			172, 33, 124, 146, 56, 247, 241, 164, 92, 106, 225, 180, 77, 238,
+			45, 152, 254, 128, 98, 58, 129, 144, 217, 180, 139, 78, 8, 234,
+			33, 155, 10, 25, 177, 122, 157, 80, 254, 103, 229, 146, 100, 176,
+			171, 212, 46, 64, 148, 76, 110, 66, 70, 172, 16, 140, 24, 25,
+			105, 234, 12, 96, 194, 200, 72, 207, 62, 250, 17, 164, 90, 64,
+			140, 140, 89, 220, 121, 31, 82, 206, 99, 42, 113, 178, 12, 174,
+			22, 90, 208, 202, 239, 51, 239, 21, 61, 177, 78, 134, 193, 32,
+			186, 164, 192, 17, 53, 155, 41, 223, 220, 40, 150, 221, 188, 87,
+			25, 143, 80, 172, 191, 10, 227, 115, 33, 212, 65, 161, 88, 228,
+			254, 90, 121, 43, 8, 126, 65, 195, 9, 207, 32, 219, 111, 48,
+			249, 77, 55, 144, 45, 200, 238, 14, 96, 209, 141, 158, 254, 0,
+			38, 140, 140, 29, 56, 72, 239, 85, 189, 196, 140, 76, 88, 93,
+			206, 17, 158, 45, 60, 85, 159, 135, 38, 139, 72, 168, 21, 28,
+			19, 159, 133, 96, 196, 200, 68, 83, 91, 0, 19, 70, 38, 88,
+			39, 125, 187, 102, 38, 97, 100, 202, 114, 156, 215, 161, 154, 5,
+			39, 251, 163, 114, 7, 133, 18, 197, 66, 106, 33, 243, 84, 199,
+			211, 201, 185, 228, 144, 207, 225, 226, 15, 190, 14, 132, 62, 188,
+			123, 187, 37, 249, 30, 228, 250, 126, 97, 181, 36, 239, 176, 160,
+			242, 152, 52, 209, 15, 253, 61, 126, 125, 173, 186, 94, 12, 245,
+			133, 196, 4, 109, 33, 24, 49, 50, 213, 20, 112, 144, 8, 218,
+			123, 251, 32, 159, 77, 3, 228, 123, 34, 247, 90, 251, 157, 71,
+			160, 39, 139, 153, 180, 180, 105, 134, 73, 1, 17, 68, 10, 222,
+			86, 148, 143, 176, 180, 134, 225, 157, 182, 226, 21, 189, 226, 53,
+			183, 4, 79, 165, 186, 1, 43, 38, 48, 134, 96, 196, 200, 189,
+			77, 189, 1, 76, 24, 185, 183, 127, 128, 206, 40, 2, 98, 140,
+			220, 103, 245, 58, 39, 162, 4, 84, 203, 28, 154, 150, 9, 242,
+			36, 95, 119, 144, 17, 106, 53, 6, 104, 66, 48, 98, 228, 190,
+			208, 122, 136, 17, 70, 238, 235, 217, 39, 197, 5, 129, 64, 196,
+			15, 88, 3, 206, 201, 29, 173, 234, 203, 210, 219, 110, 217, 142,
+			9, 84, 33, 24, 49, 242, 64, 211, 190, 0, 38, 140, 60, 224,
+			244, 211, 159, 194, 32, 63, 16, 179, 30, 193, 105, 226, 252, 0,
+			201, 237, 58, 148, 172, 74, 185, 39, 23, 164, 77, 162, 24, 130,
+			112, 74, 75, 209, 190, 190, 185, 21, 132, 194, 229, 215, 56, 228,
+			204, 207, 151, 75, 67, 85, 109, 240, 81, 220, 230, 229, 149, 21,
+			175, 162, 108, 214, 54, 33, 246, 189, 185, 63, 87, 170, 72, 177,
+			32, 39, 106, 222, 147, 81, 175, 42, 158, 235, 11, 185, 123, 181,
+			188, 89, 29, 13, 101, 104, 245, 181, 214, 226, 174, 95, 45, 172,
+			110, 150, 55, 149, 6, 176, 165, 27, 93, 115, 175, 121, 148, 155,
+			155, 95, 160, 122, 189, 236, 87, 245, 238, 188, 139, 32, 188, 87,
+			11, 66, 177, 160, 31, 161, 157, 180, 8, 227, 2, 161, 93, 166,
+			45, 199, 121, 137, 98, 142, 52, 179, 15, 27, 234, 187, 252, 234,
+			102, 161, 88, 29, 43, 64, 128, 58, 233, 71, 41, 9, 134, 12,
+			0, 33, 3, 86, 112, 194, 211, 122, 110, 56, 235, 172, 30, 43,
+			112, 188, 34, 211, 86, 8, 70, 140, 76, 155, 197, 2, 174, 87,
+			100, 186, 183, 143, 190, 23, 41, 242, 16, 35, 103, 172, 65, 231,
+			205, 40, 66, 159, 27, 4, 231, 151, 252, 21, 250, 224, 86, 69,
+			102, 61, 245, 4, 119, 228, 143, 90, 183, 26, 76, 250, 50, 81,
+			245, 224, 40, 133, 200, 151, 213, 0, 19, 100, 104, 28, 11, 146,
+			77, 26, 143, 9, 201, 79, 131, 66, 8, 184, 177, 89, 62, 6,
+			255, 102, 7, 67, 157, 66, 49, 65, 100, 8, 22, 68, 55, 237,
+			15, 96, 194, 200, 25, 126, 136, 158, 87, 125, 194, 140, 156, 179,
+			250, 156, 7, 67, 163, 168, 39, 163, 73, 237, 31, 168, 48, 70,
+			235, 90, 243, 184, 186, 113, 14, 181, 45, 86, 243, 185, 80, 219,
+			130, 97, 231, 154, 186, 2, 152, 48, 114, 110, 95, 175, 124, 79,
+			142, 51, 235, 81, 60, 103, 201, 199, 199, 248, 59, 19, 10, 28,
+			85, 175, 203, 228, 49, 204, 156, 131, 38, 0, 88, 100, 91, 12,
+			114, 132, 54, 235, 199, 94, 242, 24, 78, 104, 8, 49, 242, 88,
+			99, 139, 134, 8, 35, 143, 181, 119, 208, 97, 245, 208, 75, 178,
+			248, 188, 211, 47, 157, 128, 195, 161, 6, 106, 113, 138, 189, 39,
+			43, 195, 78, 43, 253, 35, 219, 213, 167, 33, 194, 72, 118, 96,
+			191, 134, 18, 140, 100, 15, 164, 105, 27, 77, 0, 244, 142, 68,
+			3, 35, 217, 131, 231, 232, 97, 21, 189, 129, 44, 226, 118, 167,
+			167, 190, 118, 166, 112, 8, 137, 180, 136, 109, 13, 33, 70, 22,
+			227, 77, 26, 34, 140, 44, 182, 182, 209, 33, 21, 85, 129, 92,
+			194, 221, 142, 35, 209, 137, 197, 181, 75, 7, 196, 86, 112, 201,
+			48, 69, 108, 4, 151, 26, 219, 53, 36, 176, 116, 118, 73, 5,
+			18, 91, 204, 186, 140, 95, 108, 57, 99, 245, 185, 237, 194, 201,
+			104, 183, 102, 132, 192, 191, 108, 154, 17, 226, 254, 114, 35, 211,
+			16, 97, 228, 114, 183, 118, 187, 181, 18, 204, 186, 188, 239, 197,
+			68, 78, 7, 108, 37, 222, 149, 96, 228, 113, 107, 70, 49, 206,
+			146, 140, 123, 194, 26, 163, 15, 107, 151, 237, 151, 226, 165, 41,
+			71, 6, 120, 0, 83, 2, 233, 11, 170, 20, 188, 32, 20, 131,
+			142, 17, 162, 84, 57, 217, 120, 204, 102, 228, 165, 184, 95, 67,
+			136, 145, 151, 14, 12, 105, 136, 48, 242, 210, 187, 239, 209, 80,
+			130, 145, 37, 123, 82, 209, 17, 147, 116, 44, 197, 143, 201, 216,
+			86, 98, 104, 92, 156, 118, 46, 152, 40, 22, 198, 247, 119, 179,
+			88, 52, 67, 224, 150, 184, 183, 238, 22, 138, 250, 180, 39, 119,
+			148, 48, 189, 82, 181, 2, 138, 13, 145, 98, 251, 112, 13, 247,
+			196, 230, 225, 54, 246, 106, 136, 48, 226, 246, 15, 104, 40, 193,
+			136, 187, 255, 156, 34, 210, 150, 68, 186, 7, 206, 210, 89, 32,
+			50, 206, 172, 101, 156, 207, 56, 47, 0, 102, 25, 185, 23, 33,
+			96, 103, 70, 196, 81, 149, 64, 67, 133, 11, 54, 100, 197, 99,
+			140, 44, 27, 178, 226, 136, 145, 229, 198, 14, 13, 17, 70, 150,
+			187, 186, 53, 148, 96, 36, 111, 63, 166, 198, 52, 14, 99, 154,
+			143, 47, 208, 57, 160, 42, 193, 200, 10, 94, 112, 146, 60, 89,
+			218, 54, 178, 3, 206, 58, 58, 65, 155, 24, 50, 136, 45, 175,
+			8, 211, 81, 98, 35, 148, 27, 186, 18, 150, 64, 104, 32, 155,
+			145, 149, 166, 46, 13, 33, 70, 86, 186, 15, 106, 136, 48, 178,
+			50, 120, 88, 67, 130, 144, 187, 230, 21, 243, 18, 146, 121, 43,
+			71, 230, 232, 121, 32, 179, 145, 89, 107, 184, 48, 229, 60, 4,
+			116, 134, 213, 87, 165, 232, 230, 111, 159, 196, 70, 139, 145, 53,
+			67, 98, 163, 205, 200, 90, 147, 102, 86, 35, 98, 100, 173, 231,
+			144, 134, 8, 35, 107, 119, 29, 213, 80, 130, 145, 130, 153, 132,
+			141, 146, 196, 66, 252, 24, 253, 103, 36, 115, 233, 248, 13, 55,
+			144, 243, 247, 136, 95, 16, 7, 97, 101, 87, 18, 58, 44, 233,
+			173, 218, 247, 170, 161, 232, 202, 197, 194, 114, 1, 116, 130, 138,
+			188, 157, 168, 108, 194, 40, 155, 224, 12, 112, 10, 14, 46, 128,
+			164, 190, 14, 83, 215, 245, 35, 249, 131, 130, 228, 51, 161, 180,
+			31, 235, 42, 119, 176, 84, 240, 33, 64, 215, 170, 231, 87, 117,
+			104, 135, 170, 68, 47, 198, 57, 104, 2, 2, 170, 201, 164, 60,
+			64, 72, 29, 245, 224, 120, 144, 211, 198, 143, 181, 209, 75, 58,
+			167, 205, 38, 222, 239, 156, 143, 28, 76, 131, 75, 101, 229, 191,
+			108, 54, 176, 77, 125, 72, 133, 218, 225, 198, 131, 227, 106, 144,
+			235, 102, 19, 247, 134, 114, 221, 108, 246, 15, 208, 147, 58, 213,
+			205, 22, 110, 115, 70, 67, 65, 188, 182, 32, 21, 187, 97, 172,
+			76, 163, 17, 57, 73, 6, 41, 104, 182, 34, 41, 104, 182, 90,
+			90, 233, 207, 153, 20, 52, 79, 225, 38, 231, 245, 136, 95, 52,
+			72, 221, 205, 106, 121, 12, 214, 98, 32, 209, 228, 200, 202, 164,
+			232, 98, 172, 117, 24, 154, 33, 95, 142, 164, 12, 146, 97, 238,
+			192, 242, 229, 229, 9, 177, 77, 175, 110, 22, 242, 158, 142, 26,
+			50, 6, 207, 55, 254, 248, 122, 254, 240, 90, 121, 107, 172, 90,
+			30, 147, 193, 51, 55, 43, 222, 216, 74, 161, 88, 245, 42, 99,
+			2, 151, 31, 78, 46, 243, 148, 220, 138, 84, 114, 153, 167, 26,
+			41, 157, 147, 161, 18, 94, 129, 26, 222, 132, 144, 243, 8, 79,
+			6, 49, 205, 220, 136, 30, 166, 28, 199, 53, 55, 234, 14, 240,
+			137, 32, 30, 194, 43, 80, 130, 209, 51, 42, 28, 130, 253, 74,
+			132, 95, 133, 102, 157, 251, 100, 216, 16, 69, 191, 188, 205, 202,
+			155, 92, 39, 117, 2, 123, 1, 45, 97, 127, 250, 87, 70, 253,
+			233, 95, 137, 26, 91, 67, 254, 244, 175, 68, 29, 140, 182, 73,
+			48, 33, 90, 237, 124, 21, 122, 20, 18, 48, 138, 130, 119, 37,
+			152, 245, 42, 100, 159, 167, 191, 103, 60, 238, 95, 143, 112, 167,
+			243, 65, 28, 154, 6, 59, 201, 48, 201, 156, 180, 113, 180, 224,
+			141, 242, 148, 23, 103, 53, 49, 166, 106, 227, 0, 20, 167, 40,
+			79, 205, 45, 94, 88, 202, 93, 94, 72, 73, 67, 247, 135, 95,
+			32, 42, 12, 195, 175, 35, 148, 167, 231, 114, 187, 255, 152, 205,
+			101, 228, 143, 126, 181, 2, 63, 74, 22, 240, 197, 108, 42, 19,
+			253, 74, 235, 110, 67, 126, 109, 24, 168, 153, 100, 46, 181, 179,
+			5, 248, 59, 114, 121, 162, 109, 200, 181, 113, 159, 52, 232, 83,
+			182, 124, 139, 153, 217, 122, 132, 132, 124, 231, 95, 31, 245, 157,
+			127, 125, 48, 22, 136, 8, 176, 131, 209, 187, 129, 209, 152, 89,
+			111, 64, 248, 156, 51, 192, 207, 169, 184, 105, 193, 146, 83, 126,
+			180, 227, 26, 49, 182, 161, 114, 179, 6, 145, 0, 91, 246, 105,
+			144, 8, 208, 233, 215, 96, 66, 128, 3, 103, 33, 177, 171, 0,
+			133, 112, 181, 222, 128, 246, 159, 161, 143, 65, 203, 132, 89, 111,
+			20, 67, 60, 45, 117, 8, 101, 176, 7, 15, 14, 99, 209, 20,
+			118, 242, 72, 4, 86, 123, 74, 34, 154, 168, 100, 80, 221, 16,
+			72, 98, 128, 83, 247, 92, 156, 252, 222, 24, 244, 156, 64, 139,
+			29, 140, 150, 40, 182, 48, 179, 223, 134, 26, 254, 79, 132, 156,
+			43, 124, 70, 185, 58, 43, 207, 232, 138, 187, 252, 164, 15, 167,
+			158, 213, 81, 94, 117, 253, 39, 225, 152, 179, 226, 185, 226, 100,
+			103, 178, 246, 8, 106, 193, 204, 83, 5, 128, 217, 101, 217, 77,
+			29, 151, 235, 78, 48, 235, 109, 210, 238, 143, 88, 56, 206, 236,
+			159, 70, 248, 237, 200, 130, 21, 129, 133, 38, 174, 11, 46, 83,
+			203, 194, 164, 129, 217, 239, 68, 248, 125, 136, 56, 105, 158, 212,
+			55, 71, 219, 32, 128, 198, 180, 123, 115, 16, 51, 173, 230, 94,
+			109, 55, 1, 47, 216, 128, 33, 177, 240, 59, 17, 237, 165, 247,
+			83, 91, 128, 66, 6, 188, 11, 89, 239, 65, 182, 115, 132, 39,
+			211, 11, 59, 3, 44, 85, 195, 33, 227, 84, 162, 85, 172, 210,
+			253, 190, 11, 89, 161, 2, 36, 10, 154, 186, 130, 2, 34, 10,
+			246, 245, 210, 14, 93, 144, 16, 141, 245, 189, 7, 197, 104, 7,
+			109, 84, 69, 98, 241, 191, 27, 217, 115, 112, 87, 137, 101, 198,
+			223, 95, 68, 214, 121, 135, 155, 73, 25, 244, 53, 58, 49, 117,
+			67, 200, 134, 79, 90, 131, 2, 192, 209, 214, 23, 20, 16, 81,
+			48, 176, 63, 40, 72, 136, 130, 3, 105, 202, 40, 85, 5, 48,
+			69, 127, 17, 29, 60, 71, 115, 48, 10, 136, 217, 239, 71, 248,
+			215, 16, 113, 102, 118, 29, 5, 120, 121, 190, 29, 1, 108, 6,
+			64, 144, 246, 126, 68, 187, 232, 0, 116, 23, 98, 210, 124, 0,
+			89, 221, 78, 51, 200, 58, 64, 104, 186, 38, 69, 235, 7, 2,
+			46, 75, 225, 250, 1, 212, 212, 30, 20, 16, 81, 208, 217, 69,
+			71, 21, 66, 196, 172, 15, 9, 254, 5, 139, 90, 82, 89, 143,
+			119, 8, 120, 247, 161, 128, 119, 82, 98, 124, 40, 224, 157, 148,
+			25, 31, 10, 120, 135, 128, 119, 31, 10, 120, 135, 52, 239, 62,
+			36, 120, 183, 8, 188, 195, 204, 254, 48, 194, 191, 131, 136, 147,
+			218, 149, 119, 242, 165, 243, 142, 152, 135, 101, 12, 178, 110, 250,
+			81, 4, 157, 197, 130, 123, 31, 69, 86, 143, 243, 62, 100, 14,
+			152, 155, 38, 68, 160, 196, 9, 170, 151, 202, 187, 84, 226, 21,
+			161, 111, 45, 187, 190, 14, 94, 175, 239, 91, 168, 18, 125, 165,
+			114, 149, 235, 253, 47, 240, 177, 85, 42, 128, 124, 194, 156, 241,
+			86, 124, 21, 164, 176, 16, 106, 136, 2, 135, 33, 215, 189, 52,
+			16, 118, 75, 210, 249, 65, 125, 63, 98, 184, 142, 97, 88, 63,
+			26, 12, 43, 134, 97, 253, 40, 106, 234, 8, 10, 136, 40, 232,
+			234, 166, 99, 170, 167, 136, 89, 31, 19, 195, 186, 223, 12, 171,
+			234, 108, 189, 113, 197, 48, 174, 31, 11, 198, 21, 195, 184, 126,
+			44, 24, 87, 12, 227, 250, 177, 96, 92, 49, 140, 235, 199, 130,
+			113, 197, 122, 92, 63, 38, 198, 245, 69, 48, 174, 132, 217, 31,
+			71, 248, 51, 136, 56, 231, 118, 29, 87, 184, 182, 80, 186, 74,
+			233, 182, 135, 86, 136, 235, 143, 35, 218, 73, 247, 67, 127, 137,
+			16, 76, 159, 64, 214, 39, 209, 163, 78, 147, 201, 107, 101, 250,
+			71, 128, 131, 159, 8, 56, 72, 128, 131, 159, 64, 77, 109, 65,
+			1, 17, 5, 172, 51, 40, 72, 48, 235, 147, 40, 113, 94, 9,
+			31, 162, 132, 207, 39, 81, 99, 154, 78, 170, 86, 17, 179, 62,
+			45, 184, 124, 200, 112, 57, 212, 157, 122, 156, 38, 192, 233, 79,
+			7, 156, 38, 192, 233, 79, 7, 156, 38, 192, 233, 79, 7, 156,
+			38, 192, 233, 79, 7, 156, 38, 154, 211, 159, 22, 156, 190, 71,
+			112, 90, 76, 236, 167, 17, 102, 206, 254, 250, 87, 4, 90, 245,
+			5, 230, 1, 51, 158, 214, 91, 159, 156, 76, 79, 235, 128, 70,
+			114, 42, 61, 141, 218, 59, 232, 31, 34, 64, 141, 152, 245, 199,
+			8, 119, 59, 31, 69, 60, 201, 175, 86, 10, 222, 138, 190, 89,
+			170, 89, 56, 50, 213, 63, 164, 154, 148, 23, 163, 218, 159, 76,
+			240, 100, 108, 197, 93, 150, 186, 74, 85, 230, 192, 155, 159, 153,
+			31, 214, 138, 241, 169, 251, 78, 62, 240, 192, 136, 12, 70, 183,
+			152, 150, 185, 97, 124, 211, 138, 202, 231, 45, 61, 185, 32, 145,
+			167, 187, 252, 164, 87, 202, 67, 104, 35, 136, 164, 50, 14, 189,
+			46, 94, 83, 23, 16, 249, 130, 191, 92, 241, 54, 220, 210, 242,
+			182, 233, 179, 80, 116, 254, 56, 232, 51, 130, 94, 53, 182, 107,
+			144, 8, 176, 179, 11, 238, 112, 132, 78, 109, 125, 1, 225, 243,
+			78, 223, 206, 107, 168, 40, 39, 133, 150, 243, 5, 132, 59, 53,
+			136, 4, 216, 213, 167, 65, 34, 192, 129, 253, 26, 76, 8, 240,
+			64, 26, 180, 28, 172, 181, 156, 47, 136, 65, 60, 6, 205, 18,
+			102, 125, 9, 225, 211, 206, 160, 244, 250, 87, 30, 130, 245, 132,
+			148, 110, 159, 216, 240, 137, 30, 58, 177, 42, 190, 132, 90, 187,
+			52, 8, 8, 247, 245, 106, 48, 33, 192, 190, 164, 106, 159, 200,
+			246, 191, 132, 156, 71, 232, 36, 180, 111, 49, 251, 203, 8, 127,
+			5, 89, 206, 161, 80, 110, 184, 53, 157, 114, 48, 95, 135, 0,
+			43, 198, 172, 47, 7, 108, 181, 144, 0, 85, 236, 58, 12, 65,
+			254, 190, 44, 164, 82, 155, 4, 19, 162, 129, 158, 175, 32, 34,
+			167, 182, 188, 98, 178, 254, 4, 89, 51, 138, 38, 121, 199, 100,
+			253, 41, 178, 198, 104, 1, 104, 138, 49, 235, 171, 8, 247, 56,
+			79, 4, 20, 5, 55, 233, 42, 229, 165, 121, 40, 11, 77, 70,
+			56, 109, 66, 117, 29, 156, 218, 229, 235, 158, 137, 145, 44, 106,
+			26, 101, 76, 81, 27, 179, 161, 173, 38, 13, 34, 1, 54, 235,
+			190, 196, 136, 0, 187, 186, 225, 48, 4, 3, 255, 95, 16, 30,
+			116, 30, 8, 229, 23, 148, 151, 254, 42, 37, 44, 68, 114, 47,
+			87, 77, 228, 33, 191, 246, 24, 42, 209, 218, 22, 32, 50, 32,
+			224, 109, 210, 35, 104, 35, 1, 118, 235, 25, 100, 19, 1, 242,
+			67, 112, 9, 139, 113, 156, 89, 127, 129, 240, 33, 231, 0, 7,
+			107, 55, 159, 215, 59, 157, 154, 150, 226, 22, 84, 55, 160, 45,
+			192, 166, 110, 13, 34, 1, 246, 12, 104, 144, 8, 240, 32, 167,
+			15, 64, 75, 9, 102, 125, 3, 225, 17, 231, 110, 62, 29, 132,
+			3, 14, 221, 15, 237, 184, 184, 50, 173, 38, 44, 248, 212, 128,
+			182, 0, 155, 244, 10, 73, 32, 1, 58, 119, 105, 144, 8, 112,
+			104, 152, 222, 7, 173, 54, 50, 235, 175, 17, 30, 118, 134, 229,
+			41, 92, 110, 254, 225, 179, 236, 46, 60, 109, 180, 224, 67, 3,
+			218, 2, 52, 61, 109, 68, 2, 236, 25, 212, 32, 17, 224, 145,
+			33, 250, 46, 41, 238, 40, 179, 190, 133, 240, 81, 231, 141, 8,
+			178, 34, 135, 175, 110, 164, 193, 46, 47, 148, 192, 166, 1, 118,
+			107, 8, 210, 91, 141, 220, 108, 248, 158, 242, 178, 85, 219, 60,
+			4, 63, 171, 200, 81, 49, 94, 144, 50, 170, 188, 204, 157, 41,
+			149, 134, 2, 200, 58, 37, 187, 87, 32, 238, 110, 5, 244, 129,
+			77, 176, 137, 240, 77, 239, 168, 13, 20, 26, 16, 9, 176, 137,
+			107, 144, 8, 240, 240, 17, 250, 66, 232, 77, 19, 179, 254, 14,
+			225, 9, 103, 82, 31, 156, 84, 218, 1, 253, 102, 5, 105, 209,
+			188, 32, 179, 4, 132, 152, 11, 243, 178, 201, 2, 12, 6, 180,
+			5, 216, 164, 5, 94, 19, 18, 96, 215, 136, 6, 137, 0, 71,
+			199, 233, 52, 52, 222, 204, 172, 255, 9, 225, 49, 231, 132, 110,
+			92, 116, 75, 250, 200, 6, 45, 9, 150, 105, 187, 227, 16, 57,
+			134, 128, 102, 11, 176, 24, 208, 22, 160, 33, 160, 25, 9, 176,
+			107, 72, 131, 68, 128, 119, 143, 210, 41, 32, 160, 133, 217, 255,
+			21, 225, 111, 163, 41, 37, 82, 213, 29, 181, 158, 182, 91, 144,
+			79, 88, 134, 27, 54, 237, 181, 216, 204, 250, 175, 8, 247, 107,
+			16, 9, 112, 64, 55, 208, 66, 4, 120, 247, 168, 6, 19, 204,
+			250, 54, 178, 39, 149, 248, 106, 145, 226, 235, 219, 40, 126, 140,
+			62, 4, 20, 180, 50, 251, 31, 16, 126, 6, 77, 169, 59, 42,
+			120, 124, 19, 2, 66, 187, 126, 71, 169, 1, 179, 231, 128, 150,
+			86, 155, 89, 255, 16, 208, 210, 138, 4, 56, 112, 84, 131, 68,
+			128, 35, 247, 104, 48, 193, 172, 103, 2, 90, 90, 37, 45, 207,
+			8, 90, 94, 0, 180, 180, 49, 251, 31, 17, 254, 14, 154, 114,
+			198, 111, 135, 22, 25, 54, 45, 68, 77, 155, 13, 113, 246, 52,
+			53, 109, 16, 103, 207, 112, 166, 13, 226, 236, 25, 206, 180, 37,
+			152, 245, 157, 128, 154, 54, 73, 205, 119, 4, 53, 82, 128, 182,
+			51, 251, 159, 16, 254, 46, 154, 82, 183, 73, 59, 168, 113, 67,
+			7, 185, 64, 161, 218, 65, 85, 187, 205, 172, 127, 10, 168, 106,
+			71, 2, 28, 152, 212, 32, 17, 224, 189, 247, 105, 48, 193, 172,
+			239, 6, 84, 181, 75, 170, 190, 43, 168, 74, 1, 85, 29, 204,
+			254, 103, 132, 255, 5, 77, 169, 23, 239, 186, 60, 82, 59, 242,
+			30, 68, 117, 216, 204, 250, 231, 128, 168, 14, 36, 192, 129, 113,
+			13, 18, 1, 78, 30, 215, 96, 130, 89, 255, 18, 16, 213, 33,
+			137, 250, 23, 65, 84, 6, 136, 98, 204, 254, 87, 132, 191, 135,
+			166, 156, 211, 187, 19, 37, 19, 149, 72, 59, 165, 72, 200, 187,
+			250, 187, 1, 179, 153, 245, 175, 1, 133, 12, 9, 112, 96, 76,
+			131, 68, 128, 199, 166, 52, 152, 96, 214, 247, 2, 10, 153, 164,
+			240, 123, 130, 194, 25, 160, 176, 147, 89, 223, 71, 248, 81, 53,
+			148, 38, 23, 65, 212, 210, 164, 206, 141, 92, 148, 166, 206, 24,
+			160, 209, 202, 68, 39, 18, 96, 163, 222, 43, 58, 137, 0, 7,
+			14, 104, 48, 33, 192, 131, 231, 21, 77, 157, 146, 166, 239, 35,
+			158, 86, 162, 175, 139, 89, 207, 34, 156, 114, 38, 107, 104, 146,
+			155, 180, 80, 107, 224, 150, 200, 175, 186, 149, 74, 93, 221, 166,
+			43, 6, 24, 52, 57, 93, 72, 128, 141, 90, 242, 116, 17, 1,
+			246, 104, 229, 170, 43, 33, 192, 62, 173, 200, 116, 73, 114, 158,
+			69, 206, 52, 125, 131, 220, 88, 186, 153, 253, 111, 8, 255, 0,
+			77, 58, 255, 63, 190, 0, 118, 243, 242, 114, 118, 163, 82, 94,
+			246, 252, 154, 237, 20, 46, 150, 32, 47, 119, 228, 1, 104, 68,
+			165, 95, 220, 229, 70, 89, 5, 76, 134, 187, 100, 64, 53, 166,
+			31, 154, 252, 49, 183, 148, 31, 91, 21, 27, 137, 238, 94, 183,
+			197, 172, 127, 11, 4, 107, 119, 76, 128, 77, 90, 221, 233, 70,
+			2, 100, 186, 123, 221, 68, 128, 253, 251, 149, 38, 215, 157, 16,
+			157, 57, 240, 3, 116, 76, 245, 183, 91, 246, 247, 7, 200, 158,
+			160, 121, 42, 116, 39, 251, 85, 184, 225, 167, 48, 114, 46, 194,
+			49, 216, 164, 146, 214, 91, 42, 188, 171, 155, 215, 25, 115, 176,
+			46, 148, 248, 176, 142, 37, 61, 53, 121, 223, 200, 238, 6, 10,
+			77, 148, 196, 132, 22, 255, 42, 28, 235, 161, 115, 212, 138, 193,
+			221, 200, 107, 48, 238, 85, 214, 50, 145, 36, 203, 123, 61, 63,
+			72, 253, 62, 242, 242, 208, 66, 99, 49, 121, 121, 242, 26, 44,
+			181, 251, 24, 164, 164, 177, 94, 131, 123, 246, 137, 205, 37, 6,
+			23, 39, 175, 197, 184, 69, 229, 185, 73, 235, 94, 77, 135, 30,
+			243, 66, 38, 83, 18, 5, 146, 31, 37, 52, 136, 5, 216, 212,
+			44, 244, 157, 24, 220, 175, 190, 14, 227, 86, 103, 184, 62, 198,
+			53, 215, 231, 87, 61, 175, 164, 237, 184, 12, 86, 65, 202, 235,
+			176, 12, 189, 46, 64, 192, 211, 220, 66, 95, 2, 88, 9, 179,
+			94, 143, 113, 147, 51, 127, 11, 172, 43, 69, 119, 117, 85, 229,
+			96, 218, 112, 215, 197, 25, 205, 125, 82, 106, 12, 203, 158, 12,
+			129, 118, 205, 171, 112, 101, 19, 108, 26, 23, 103, 142, 215, 99,
+			108, 107, 16, 11, 176, 145, 210, 199, 168, 80, 255, 237, 55, 225,
+			134, 183, 98, 228, 76, 243, 172, 202, 202, 33, 205, 70, 139, 155,
+			235, 37, 153, 231, 91, 155, 189, 130, 37, 197, 181, 130, 183, 181,
+			219, 229, 168, 188, 27, 21, 205, 189, 9, 39, 122, 233, 253, 212,
+			178, 196, 177, 223, 122, 11, 198, 93, 206, 8, 151, 174, 6, 38,
+			29, 200, 86, 73, 223, 199, 168, 198, 214, 60, 48, 136, 147, 211,
+			93, 94, 8, 188, 69, 141, 132, 37, 175, 3, 222, 130, 85, 116,
+			114, 121, 25, 240, 22, 204, 58, 233, 31, 32, 138, 45, 139, 217,
+			63, 135, 27, 222, 139, 145, 243, 31, 17, 207, 152, 236, 18, 102,
+			46, 195, 204, 222, 153, 71, 28, 214, 176, 188, 23, 186, 160, 141,
+			223, 41, 79, 86, 121, 209, 115, 253, 42, 132, 40, 40, 175, 240,
+			43, 128, 227, 10, 92, 39, 95, 137, 122, 52, 92, 225, 23, 22,
+			179, 57, 245, 104, 248, 160, 204, 130, 7, 37, 115, 243, 57, 8,
+			155, 65, 213, 111, 187, 223, 150, 8, 150, 137, 67, 217, 207, 225,
+			68, 59, 200, 69, 11, 55, 48, 251, 29, 24, 191, 19, 159, 119,
+			38, 118, 94, 22, 4, 125, 218, 73, 188, 100, 157, 5, 172, 123,
+			135, 102, 157, 5, 172, 123, 7, 86, 55, 231, 22, 176, 238, 29,
+			184, 131, 105, 48, 193, 172, 119, 98, 59, 13, 39, 62, 75, 221,
+			161, 188, 19, 199, 207, 209, 223, 65, 64, 14, 98, 214, 123, 48,
+			30, 112, 126, 21, 65, 52, 15, 29, 42, 88, 200, 62, 239, 186,
+			180, 36, 87, 83, 68, 26, 215, 240, 171, 19, 147, 83, 199, 239,
+			133, 123, 118, 151, 231, 221, 210, 106, 81, 134, 128, 86, 193, 48,
+			168, 26, 154, 114, 49, 207, 135, 32, 236, 71, 161, 234, 13, 153,
+			91, 165, 232, 189, 195, 253, 83, 199, 30, 24, 57, 101, 114, 122,
+			213, 4, 50, 45, 151, 234, 224, 215, 138, 184, 5, 87, 10, 239,
+			9, 248, 128, 160, 39, 141, 251, 52, 72, 4, 232, 244, 211, 183,
+			136, 41, 20, 99, 246, 175, 224, 134, 191, 194, 200, 121, 53, 218,
+			229, 13, 33, 100, 13, 165, 182, 132, 31, 134, 176, 223, 205, 186,
+			88, 76, 12, 113, 194, 253, 21, 156, 232, 134, 119, 134, 88, 156,
+			217, 239, 199, 248, 131, 56, 6, 50, 62, 6, 239, 12, 170, 224,
+			55, 196, 80, 197, 172, 6, 102, 255, 6, 198, 255, 9, 19, 231,
+			93, 136, 47, 148, 171, 98, 158, 130, 181, 5, 200, 119, 117, 51,
+			171, 137, 8, 95, 164, 74, 131, 106, 169, 54, 129, 209, 218, 166,
+			239, 241, 124, 97, 5, 88, 90, 133, 140, 90, 176, 25, 70, 206,
+			203, 227, 123, 117, 30, 76, 57, 198, 215, 243, 187, 246, 79, 12,
+			66, 12, 130, 98, 252, 6, 142, 119, 211, 39, 168, 109, 197, 100,
+			84, 140, 223, 196, 214, 93, 206, 163, 145, 45, 194, 48, 94, 146,
+			120, 171, 205, 98, 211, 143, 238, 22, 98, 106, 235, 32, 22, 191,
+			137, 173, 131, 65, 1, 22, 5, 131, 135, 233, 131, 170, 121, 196,
+			172, 143, 96, 171, 221, 185, 135, 207, 149, 53, 42, 33, 128, 183,
+			189, 170, 20, 194, 230, 185, 90, 183, 31, 66, 143, 228, 215, 77,
+			65, 1, 22, 5, 173, 109, 116, 86, 161, 199, 204, 250, 109, 108,
+			117, 59, 15, 233, 96, 225, 98, 37, 151, 60, 47, 47, 206, 57,
+			96, 176, 9, 55, 205, 85, 99, 129, 34, 36, 153, 201, 27, 160,
+			223, 199, 52, 122, 65, 237, 111, 99, 171, 61, 40, 0, 252, 157,
+			93, 166, 61, 194, 172, 223, 193, 86, 147, 110, 207, 176, 81, 153,
+			12, 135, 130, 54, 134, 206, 123, 187, 182, 39, 68, 251, 239, 96,
+			203, 14, 10, 176, 40, 104, 164, 52, 169, 218, 179, 152, 245, 123,
+			216, 234, 83, 42, 157, 81, 150, 42, 158, 155, 223, 54, 23, 64,
+			65, 207, 170, 101, 213, 237, 80, 35, 66, 24, 254, 30, 182, 186,
+			130, 2, 44, 10, 246, 245, 210, 211, 170, 145, 24, 179, 62, 142,
+			173, 125, 206, 84, 200, 82, 71, 96, 51, 218, 162, 76, 78, 35,
+			240, 154, 44, 112, 90, 113, 212, 72, 197, 202, 250, 56, 182, 88,
+			80, 128, 69, 65, 119, 15, 189, 79, 181, 98, 51, 235, 19, 216,
+			98, 206, 209, 154, 86, 4, 207, 164, 44, 138, 166, 81, 208, 136,
+			108, 4, 31, 182, 4, 5, 88, 20, 180, 119, 128, 237, 108, 76,
+			90, 26, 125, 10, 91, 29, 206, 3, 245, 232, 87, 96, 254, 182,
+			6, 36, 142, 0, 85, 115, 80, 128, 69, 65, 91, 59, 189, 172,
+			218, 74, 48, 235, 51, 98, 194, 165, 235, 181, 21, 196, 109, 138,
+			158, 246, 193, 154, 92, 211, 177, 107, 227, 9, 4, 184, 131, 217,
+			151, 192, 162, 160, 179, 139, 14, 10, 97, 36, 86, 242, 103, 49,
+			102, 42, 93, 101, 109, 118, 98, 88, 254, 176, 83, 125, 86, 75,
+			232, 24, 172, 207, 207, 98, 117, 209, 29, 131, 157, 234, 179, 130,
+			115, 247, 3, 66, 196, 236, 207, 97, 252, 121, 60, 233, 12, 237,
+			68, 169, 175, 95, 67, 238, 127, 166, 21, 177, 15, 124, 46, 104,
+			69, 44, 211, 207, 225, 198, 110, 13, 18, 1, 246, 246, 73, 169,
+			138, 81, 66, 52, 227, 124, 30, 31, 147, 29, 195, 8, 118, 196,
+			63, 198, 246, 5, 80, 165, 99, 250, 114, 255, 243, 216, 158, 128,
+			219, 183, 152, 56, 172, 125, 17, 227, 47, 225, 115, 74, 51, 148,
+			39, 24, 29, 70, 107, 181, 226, 150, 84, 146, 65, 35, 123, 53,
+			105, 98, 197, 124, 17, 99, 3, 198, 4, 168, 116, 252, 24, 44,
+			240, 47, 98, 214, 175, 65, 34, 192, 3, 7, 21, 165, 56, 33,
+			154, 229, 95, 194, 103, 21, 165, 24, 40, 253, 18, 182, 207, 208,
+			227, 64, 23, 97, 214, 151, 197, 214, 125, 100, 143, 251, 234, 29,
+			36, 17, 27, 190, 106, 215, 32, 18, 96, 199, 62, 13, 2, 78,
+			167, 31, 110, 164, 99, 216, 98, 246, 159, 98, 252, 21, 60, 169,
+			110, 164, 225, 180, 123, 69, 182, 112, 5, 142, 222, 69, 161, 74,
+			129, 10, 164, 48, 88, 54, 179, 254, 20, 99, 221, 41, 177, 222,
+			255, 20, 15, 220, 165, 65, 34, 192, 161, 97, 213, 71, 43, 33,
+			26, 24, 249, 10, 62, 166, 152, 175, 46, 160, 191, 34, 152, 127,
+			10, 72, 136, 49, 251, 207, 49, 254, 26, 158, 116, 238, 142, 94,
+			138, 135, 78, 225, 202, 206, 72, 19, 102, 104, 137, 197, 152, 245,
+			231, 193, 204, 16, 82, 225, 207, 181, 146, 25, 131, 27, 229, 63,
+			199, 240, 226, 4, 96, 66, 180, 212, 245, 53, 51, 51, 98, 192,
+			239, 175, 10, 126, 75, 226, 164, 229, 163, 245, 53, 65, 220, 44,
+			16, 103, 51, 251, 235, 24, 255, 5, 158, 4, 17, 236, 201, 69,
+			20, 225, 123, 157, 187, 217, 157, 103, 74, 69, 144, 29, 99, 214,
+			215, 3, 114, 133, 168, 249, 186, 86, 236, 98, 112, 249, 252, 117,
+			172, 12, 115, 98, 216, 78, 136, 182, 59, 255, 194, 176, 78, 154,
+			60, 90, 127, 33, 168, 123, 4, 168, 139, 51, 235, 27, 24, 15,
+			57, 83, 60, 112, 248, 84, 166, 27, 87, 194, 238, 182, 87, 196,
+			153, 67, 166, 6, 145, 169, 36, 12, 69, 113, 11, 80, 24, 208,
+			22, 96, 147, 94, 90, 66, 62, 125, 3, 171, 155, 220, 24, 220,
+			89, 127, 3, 31, 57, 106, 226, 35, 125, 251, 16, 205, 61, 207,
+			240, 72, 202, 146, 227, 223, 43, 64, 146, 243, 35, 8, 239, 52,
+			248, 26, 139, 198, 207, 149, 171, 226, 192, 85, 215, 25, 250, 40,
+			109, 86, 15, 120, 50, 242, 12, 214, 62, 190, 56, 211, 164, 126,
+			128, 0, 52, 199, 116, 100, 16, 114, 11, 191, 97, 172, 227, 130,
+			76, 210, 184, 151, 47, 84, 203, 21, 191, 215, 218, 219, 61, 90,
+			215, 99, 251, 131, 64, 42, 177, 128, 14, 19, 77, 229, 8, 109,
+			10, 57, 141, 41, 15, 102, 69, 106, 80, 206, 206, 208, 54, 165,
+			235, 45, 201, 227, 160, 142, 75, 82, 39, 192, 72, 200, 113, 61,
+			211, 170, 190, 146, 160, 207, 102, 105, 219, 154, 228, 220, 210, 70,
+			165, 112, 205, 93, 222, 134, 64, 75, 173, 83, 135, 35, 120, 20,
+			119, 245, 191, 11, 178, 106, 166, 117, 45, 2, 15, 158, 167, 173,
+			209, 26, 236, 32, 237, 63, 55, 159, 155, 77, 103, 115, 75, 11,
+			153, 244, 197, 228, 244, 229, 157, 222, 206, 240, 67, 46, 213, 142,
+			24, 165, 246, 194, 226, 233, 217, 244, 116, 59, 62, 53, 249, 76,
+			114, 188, 54, 24, 143, 30, 228, 110, 213, 174, 63, 113, 67, 83,
+			95, 200, 223, 28, 252, 56, 166, 77, 170, 74, 186, 234, 173, 215,
+			157, 11, 147, 218, 43, 31, 235, 16, 60, 245, 189, 242, 191, 149,
+			140, 105, 199, 252, 125, 212, 170, 184, 165, 39, 97, 86, 168, 56,
+			18, 80, 32, 230, 139, 155, 207, 123, 21, 25, 71, 103, 79, 63,
+			115, 89, 177, 54, 250, 200, 109, 122, 182, 135, 163, 143, 136, 62,
+			149, 171, 158, 10, 197, 4, 127, 159, 74, 61, 147, 60, 93, 235,
+			214, 31, 230, 195, 145, 186, 236, 154, 40, 84, 189, 117, 127, 226,
+			134, 248, 71, 192, 63, 4, 71, 232, 79, 246, 82, 155, 89, 137,
+			134, 75, 136, 126, 230, 255, 53, 126, 208, 151, 111, 233, 7, 173,
+			100, 238, 109, 122, 66, 235, 193, 250, 33, 184, 58, 139, 63, 9,
+			35, 29, 13, 47, 166, 73, 233, 244, 220, 221, 112, 10, 57, 39,
+			120, 82, 154, 48, 8, 33, 22, 184, 252, 72, 97, 177, 187, 255,
+			174, 118, 4, 238, 78, 180, 25, 135, 154, 30, 220, 23, 118, 168,
+			1, 208, 56, 212, 12, 236, 238, 80, 163, 58, 25, 113, 168, 25,
+			136, 56, 212, 12, 68, 28, 106, 6, 218, 59, 232, 138, 118, 168,
+			57, 128, 83, 206, 101, 126, 37, 44, 211, 175, 68, 50, 224, 234,
+			172, 184, 114, 204, 221, 42, 191, 178, 82, 113, 215, 189, 173, 114,
+			229, 201, 165, 171, 133, 167, 202, 87, 95, 54, 158, 73, 45, 105,
+			193, 52, 151, 188, 96, 178, 65, 94, 9, 220, 113, 98, 162, 161,
+			68, 200, 29, 231, 64, 99, 119, 200, 29, 231, 64, 111, 95, 200,
+			29, 231, 128, 51, 19, 117, 199, 57, 48, 112, 154, 126, 194, 100,
+			83, 29, 196, 119, 77, 56, 191, 142, 246, 228, 133, 124, 240, 24,
+			167, 124, 30, 204, 167, 101, 180, 79, 120, 42, 53, 53, 84, 162,
+			110, 49, 85, 228, 110, 50, 42, 63, 146, 78, 114, 161, 37, 239,
+			239, 188, 24, 58, 54, 117, 124, 228, 20, 143, 238, 231, 218, 13,
+			76, 58, 74, 4, 254, 163, 144, 32, 53, 112, 21, 138, 49, 50,
+			104, 56, 1, 238, 202, 141, 173, 33, 87, 161, 65, 117, 55, 214,
+			0, 207, 234, 131, 157, 119, 141, 43, 231, 12, 208, 175, 201, 97,
+			91, 179, 70, 26, 136, 144, 187, 236, 81, 186, 164, 93, 139, 134,
+			176, 235, 100, 162, 124, 241, 107, 25, 163, 58, 59, 78, 121, 74,
+			254, 85, 143, 55, 209, 222, 107, 183, 35, 75, 180, 16, 118, 80,
+			26, 106, 234, 8, 57, 40, 13, 177, 190, 144, 131, 210, 144, 241,
+			176, 34, 9, 70, 134, 14, 92, 81, 253, 32, 208, 143, 33, 254,
+			82, 122, 84, 185, 47, 145, 17, 252, 136, 211, 199, 179, 81, 179,
+			161, 218, 73, 109, 197, 68, 197, 176, 167, 210, 136, 113, 136, 178,
+			8, 35, 35, 157, 218, 149, 196, 74, 48, 50, 210, 253, 194, 168,
+			99, 210, 200, 190, 135, 233, 89, 229, 152, 68, 238, 193, 51, 206,
+			41, 126, 1, 238, 244, 188, 170, 91, 40, 122, 249, 136, 119, 185,
+			34, 97, 99, 179, 178, 81, 246, 119, 93, 102, 49, 192, 148, 8,
+			185, 40, 221, 211, 216, 21, 114, 81, 186, 103, 95, 111, 200, 69,
+			233, 158, 190, 233, 168, 139, 210, 61, 253, 73, 154, 213, 46, 74,
+			99, 248, 152, 115, 134, 207, 87, 228, 155, 191, 22, 34, 129, 7,
+			63, 40, 24, 234, 230, 186, 92, 210, 164, 12, 249, 117, 238, 199,
+			181, 199, 145, 37, 176, 26, 72, 180, 209, 212, 31, 242, 84, 26,
+			27, 184, 39, 228, 169, 52, 54, 62, 65, 79, 235, 120, 4, 147,
+			248, 1, 226, 220, 203, 149, 218, 193, 139, 66, 134, 203, 215, 40,
+			173, 186, 236, 122, 9, 31, 132, 37, 152, 140, 119, 211, 163, 65,
+			88, 130, 227, 214, 93, 206, 190, 218, 155, 179, 210, 166, 191, 35,
+			242, 192, 113, 235, 96, 36, 242, 192, 241, 193, 195, 116, 33, 136,
+			60, 112, 194, 106, 119, 146, 124, 190, 84, 220, 14, 189, 107, 138,
+			5, 171, 230, 117, 237, 116, 23, 147, 219, 56, 35, 7, 35, 24,
+			142, 76, 112, 162, 38, 50, 193, 137, 214, 54, 250, 64, 16, 153,
+			224, 126, 171, 205, 25, 225, 201, 210, 54, 92, 201, 203, 69, 189,
+			229, 93, 189, 21, 102, 65, 236, 253, 22, 141, 68, 34, 184, 191,
+			165, 149, 54, 41, 135, 47, 114, 18, 31, 209, 94, 88, 182, 128,
+			218, 67, 254, 90, 39, 59, 120, 200, 95, 235, 228, 225, 187, 232,
+			227, 224, 193, 97, 189, 176, 225, 18, 114, 230, 194, 97, 54, 32,
+			24, 178, 152, 172, 101, 191, 160, 39, 175, 14, 169, 1, 111, 40,
+			183, 24, 180, 251, 229, 94, 36, 24, 241, 194, 68, 167, 244, 236,
+			136, 51, 235, 17, 60, 45, 247, 34, 4, 123, 17, 128, 199, 148,
+			163, 7, 57, 131, 153, 115, 184, 190, 252, 13, 73, 14, 53, 21,
+			165, 167, 238, 25, 181, 80, 164, 159, 238, 25, 181, 31, 73, 47,
+			221, 51, 237, 29, 244, 94, 237, 172, 113, 14, 159, 63, 166, 238,
+			189, 100, 31, 234, 186, 140, 20, 194, 248, 197, 238, 114, 206, 224,
+			23, 61, 57, 167, 100, 170, 116, 152, 61, 167, 100, 170, 180, 54,
+			62, 215, 121, 126, 2, 70, 5, 201, 219, 21, 146, 182, 83, 176,
+			50, 181, 237, 49, 57, 111, 143, 209, 127, 64, 202, 169, 129, 204,
+			225, 23, 56, 223, 64, 181, 44, 23, 4, 12, 249, 1, 215, 149,
+			180, 87, 221, 23, 96, 222, 19, 154, 38, 196, 13, 128, 204, 96,
+			133, 234, 54, 47, 87, 224, 149, 137, 155, 195, 174, 116, 188, 130,
+			123, 215, 73, 94, 45, 243, 57, 62, 12, 247, 200, 161, 8, 8,
+			106, 106, 141, 140, 74, 63, 5, 209, 48, 23, 59, 14, 196, 197,
+			216, 44, 21, 94, 190, 9, 73, 105, 158, 28, 167, 124, 58, 136,
+			170, 34, 74, 140, 113, 27, 188, 251, 23, 74, 252, 74, 198, 19,
+			229, 97, 233, 126, 197, 112, 81, 8, 198, 57, 195, 69, 49, 125,
+			231, 204, 40, 137, 205, 101, 174, 189, 67, 67, 9, 70, 230, 216,
+			195, 138, 105, 106, 35, 154, 235, 124, 8, 238, 68, 224, 73, 113,
+			1, 103, 166, 156, 209, 93, 28, 82, 133, 242, 46, 128, 122, 83,
+			69, 236, 42, 11, 134, 8, 177, 171, 44, 152, 161, 20, 187, 202,
+			130, 25, 74, 146, 96, 214, 66, 103, 102, 82, 13, 165, 220, 86,
+			30, 83, 219, 35, 82, 246, 139, 36, 99, 75, 185, 134, 196, 62,
+			147, 195, 79, 40, 195, 141, 192, 29, 53, 68, 196, 78, 151, 212,
+			168, 196, 71, 216, 178, 5, 146, 126, 13, 33, 70, 114, 202, 41,
+			21, 193, 30, 148, 83, 78, 169, 8, 246, 160, 220, 216, 227, 138,
+			24, 181, 7, 229, 38, 94, 4, 119, 31, 72, 176, 250, 34, 102,
+			206, 113, 161, 144, 87, 198, 76, 238, 187, 80, 154, 92, 185, 47,
+			249, 210, 171, 191, 206, 156, 143, 1, 10, 205, 40, 177, 249, 92,
+			52, 163, 37, 54, 159, 139, 161, 72, 208, 111, 107, 163, 151, 158,
+			231, 77, 65, 144, 205, 110, 143, 203, 142, 193, 21, 218, 178, 96,
+			42, 102, 61, 136, 204, 171, 239, 52, 116, 100, 94, 13, 179, 147,
+			180, 41, 192, 234, 247, 98, 78, 118, 132, 161, 11, 144, 101, 194,
+			117, 239, 126, 35, 162, 52, 248, 141, 57, 180, 103, 33, 149, 185,
+			144, 206, 102, 119, 198, 189, 106, 167, 205, 90, 53, 77, 205, 64,
+			236, 171, 30, 202, 116, 73, 114, 230, 66, 122, 46, 157, 205, 165,
+			50, 237, 152, 181, 82, 154, 206, 102, 23, 83, 178, 30, 97, 140,
+			182, 158, 73, 167, 102, 103, 150, 102, 82, 103, 100, 153, 197, 122,
+			105, 87, 80, 118, 49, 57, 171, 107, 199, 126, 8, 231, 189, 111,
+			55, 201, 243, 222, 221, 255, 223, 121, 239, 255, 142, 243, 94, 86,
+			250, 229, 182, 55, 112, 228, 156, 5, 246, 153, 168, 58, 161, 169,
+			166, 55, 124, 253, 202, 205, 253, 66, 117, 55, 247, 131, 251, 2,
+			199, 215, 246, 88, 27, 61, 174, 29, 95, 25, 222, 239, 28, 229,
+			51, 123, 26, 156, 104, 77, 40, 112, 106, 101, 17, 167, 86, 214,
+			63, 64, 47, 104, 167, 214, 46, 220, 169, 172, 88, 194, 249, 238,
+			229, 115, 90, 181, 12, 239, 213, 146, 89, 235, 229, 107, 158, 214,
+			16, 165, 23, 73, 84, 164, 41, 71, 215, 46, 220, 26, 114, 116,
+			237, 234, 96, 96, 27, 2, 202, 207, 62, 220, 231, 44, 236, 222,
+			148, 180, 53, 9, 208, 66, 88, 15, 153, 7, 86, 149, 80, 115,
+			206, 154, 144, 6, 214, 254, 120, 216, 85, 117, 31, 238, 10, 185,
+			170, 238, 219, 215, 11, 198, 46, 112, 152, 233, 195, 29, 206, 200,
+			238, 77, 11, 125, 47, 28, 107, 74, 98, 17, 59, 70, 31, 110,
+			214, 16, 102, 164, 175, 173, 93, 40, 254, 49, 56, 105, 244, 227,
+			110, 231, 212, 173, 112, 70, 29, 98, 131, 236, 238, 166, 17, 33,
+			248, 251, 165, 190, 22, 131, 39, 66, 210, 223, 217, 5, 7, 212,
+			24, 28, 47, 14, 226, 126, 231, 55, 208, 45, 154, 9, 28, 36,
+			119, 56, 225, 142, 83, 121, 54, 9, 207, 67, 173, 42, 152, 244,
+			251, 106, 49, 26, 255, 70, 237, 54, 4, 81, 206, 140, 175, 201,
+			70, 209, 45, 249, 161, 54, 221, 106, 56, 154, 9, 15, 4, 158,
+			233, 156, 216, 74, 14, 226, 30, 13, 97, 70, 14, 246, 57, 244,
+			9, 121, 227, 113, 164, 225, 110, 164, 76, 133, 224, 45, 122, 69,
+			249, 111, 8, 77, 40, 68, 172, 140, 64, 36, 84, 159, 162, 23,
+			78, 56, 191, 187, 187, 56, 28, 25, 142, 36, 186, 105, 82, 223,
+			119, 12, 225, 30, 21, 199, 44, 172, 60, 152, 103, 183, 43, 161,
+			246, 174, 40, 211, 118, 209, 209, 200, 37, 200, 80, 228, 18, 100,
+			200, 4, 65, 16, 74, 231, 80, 87, 183, 138, 84, 33, 142, 146,
+			248, 168, 115, 12, 132, 64, 117, 45, 202, 119, 217, 63, 120, 134,
+			23, 189, 50, 57, 252, 67, 119, 27, 112, 130, 13, 7, 30, 25,
+			49, 33, 3, 196, 234, 26, 49, 33, 3, 132, 46, 58, 114, 215,
+			17, 179, 47, 255, 135, 63, 67, 207, 251, 9, 66, 249, 131, 221,
+			206, 19, 196, 174, 207, 8, 207, 231, 141, 226, 71, 240, 4, 225,
+			252, 168, 148, 149, 193, 143, 32, 26, 87, 126, 29, 119, 246, 182,
+			17, 139, 190, 109, 132, 226, 183, 147, 104, 252, 246, 154, 44, 0,
+			69, 21, 21, 62, 156, 5, 160, 120, 106, 252, 153, 228, 61, 181,
+			183, 241, 134, 172, 157, 225, 64, 7, 191, 72, 104, 163, 241, 22,
+			172, 75, 120, 253, 192, 246, 15, 68, 146, 92, 220, 21, 81, 173,
+			12, 190, 224, 175, 80, 190, 11, 166, 110, 233, 5, 245, 45, 234,
+			130, 126, 128, 54, 230, 203, 203, 210, 7, 94, 37, 55, 8, 10,
+			216, 131, 58, 112, 189, 13, 141, 29, 185, 85, 99, 225, 116, 11,
+			131, 25, 218, 18, 161, 130, 29, 164, 253, 42, 156, 171, 80, 180,
+			114, 151, 23, 106, 67, 145, 38, 168, 53, 191, 144, 154, 147, 207,
+			28, 211, 179, 243, 89, 136, 67, 74, 169, 125, 33, 149, 57, 155,
+			154, 105, 39, 131, 115, 180, 53, 218, 24, 227, 116, 32, 132, 180,
+			94, 128, 211, 86, 74, 103, 82, 11, 153, 212, 116, 50, 151, 154,
+			145, 184, 85, 192, 83, 124, 234, 220, 51, 201, 20, 221, 23, 29,
+			180, 96, 80, 238, 174, 19, 197, 213, 55, 14, 158, 19, 55, 84,
+			52, 240, 188, 183, 114, 115, 240, 189, 152, 38, 192, 37, 231, 206,
+			70, 51, 194, 127, 82, 203, 127, 147, 56, 192, 170, 243, 226, 164,
+			91, 51, 127, 68, 184, 63, 75, 91, 34, 229, 130, 251, 179, 201,
+			211, 169, 217, 231, 196, 39, 120, 63, 169, 201, 195, 96, 122, 59,
+			92, 135, 77, 69, 245, 163, 63, 113, 3, 254, 148, 76, 250, 78,
+			27, 77, 232, 244, 13, 207, 107, 173, 238, 205, 182, 227, 106, 137,
+			72, 174, 57, 59, 227, 16, 11, 174, 137, 41, 41, 49, 203, 213,
+			49, 37, 22, 175, 126, 32, 87, 241, 243, 1, 139, 92, 21, 157,
+			193, 143, 112, 131, 1, 51, 122, 130, 218, 110, 126, 189, 80, 242,
+			123, 237, 189, 223, 54, 85, 53, 118, 47, 181, 171, 21, 183, 80,
+			149, 111, 145, 173, 53, 57, 18, 2, 218, 160, 78, 70, 213, 101,
+			231, 105, 155, 121, 59, 151, 175, 229, 42, 215, 203, 45, 163, 85,
+			199, 50, 173, 250, 203, 5, 248, 144, 61, 74, 91, 188, 210, 230,
+			250, 146, 86, 224, 122, 27, 225, 133, 237, 104, 125, 66, 82, 165,
+			205, 117, 209, 211, 172, 170, 157, 105, 22, 31, 107, 136, 157, 163,
+			205, 133, 82, 53, 192, 69, 1, 215, 145, 250, 184, 210, 165, 106,
+			4, 85, 83, 161, 84, 13, 99, 242, 171, 149, 0, 83, 211, 94,
+			152, 178, 213, 74, 20, 147, 95, 173, 24, 76, 143, 210, 22, 177,
+			185, 7, 168, 154, 247, 234, 160, 24, 157, 104, 7, 197, 199, 97,
+			100, 121, 183, 234, 5, 200, 90, 246, 66, 6, 57, 192, 34, 200,
+			196, 199, 6, 89, 232, 41, 188, 245, 246, 158, 194, 157, 183, 35,
+			218, 94, 59, 6, 236, 44, 141, 47, 175, 149, 11, 203, 158, 223,
+			139, 224, 69, 123, 236, 246, 6, 111, 124, 26, 190, 202, 232, 175,
+			157, 135, 168, 45, 139, 2, 225, 132, 118, 21, 78, 184, 102, 149,
+			57, 143, 210, 182, 154, 33, 101, 253, 180, 113, 189, 80, 90, 10,
+			80, 197, 50, 137, 245, 66, 201, 228, 163, 89, 119, 175, 135, 114,
+			214, 136, 31, 221, 235, 240, 163, 51, 68, 219, 106, 70, 85, 208,
+			84, 241, 86, 189, 235, 154, 38, 0, 156, 47, 16, 218, 94, 59,
+			104, 236, 165, 180, 13, 236, 72, 183, 151, 170, 149, 194, 234, 170,
+			87, 145, 65, 249, 91, 167, 78, 220, 222, 168, 143, 207, 193, 215,
+			57, 245, 113, 166, 181, 20, 129, 89, 158, 118, 84, 202, 69, 111,
+			73, 233, 233, 50, 209, 145, 12, 177, 127, 255, 109, 182, 144, 41,
+			23, 189, 76, 232, 243, 76, 123, 165, 166, 132, 29, 164, 77, 96,
+			216, 229, 47, 9, 93, 71, 137, 53, 42, 139, 22, 188, 202, 58,
+			219, 79, 41, 88, 118, 202, 223, 85, 178, 34, 40, 17, 63, 15,
+			94, 160, 173, 209, 126, 8, 153, 63, 55, 159, 75, 159, 185, 188,
+			148, 203, 164, 207, 158, 77, 101, 106, 35, 169, 55, 210, 216, 92,
+			234, 98, 42, 35, 115, 253, 36, 231, 46, 47, 233, 228, 63, 120,
+			240, 42, 109, 175, 37, 154, 29, 162, 251, 51, 243, 179, 41, 8,
+			168, 158, 150, 249, 244, 107, 81, 238, 163, 157, 115, 243, 75, 181,
+			181, 218, 17, 99, 180, 117, 33, 51, 127, 62, 53, 157, 91, 186,
+			144, 186, 112, 58, 149, 105, 199, 206, 23, 16, 109, 175, 93, 53,
+			44, 67, 155, 96, 209, 201, 212, 108, 106, 36, 39, 111, 111, 201,
+			65, 65, 18, 62, 204, 208, 188, 249, 123, 112, 153, 210, 224, 23,
+			214, 79, 247, 65, 160, 29, 177, 191, 237, 184, 92, 106, 161, 141,
+			115, 243, 234, 39, 21, 95, 94, 242, 112, 254, 210, 28, 220, 41,
+			65, 15, 161, 100, 33, 153, 201, 165, 167, 211, 11, 201, 185, 92,
+			182, 157, 12, 46, 170, 164, 74, 93, 180, 189, 190, 118, 147, 154,
+			91, 188, 208, 142, 88, 156, 146, 180, 96, 177, 248, 35, 155, 203,
+			180, 19, 241, 219, 98, 54, 149, 105, 183, 196, 95, 130, 180, 246,
+			152, 248, 113, 49, 51, 219, 110, 15, 150, 168, 45, 55, 4, 214,
+			67, 89, 46, 147, 76, 239, 224, 121, 51, 77, 40, 94, 207, 72,
+			70, 207, 164, 206, 36, 23, 103, 115, 75, 231, 210, 51, 51, 169,
+			185, 118, 44, 70, 247, 194, 226, 108, 46, 13, 215, 91, 51, 237,
+			68, 140, 252, 194, 185, 100, 54, 213, 110, 137, 141, 63, 147, 202,
+			230, 50, 233, 105, 136, 134, 47, 149, 162, 93, 146, 46, 177, 123,
+			234, 108, 246, 43, 234, 71, 127, 226, 134, 60, 105, 228, 189, 21,
+			48, 55, 249, 96, 140, 54, 135, 211, 125, 252, 208, 20, 163, 96,
+			227, 189, 133, 81, 145, 222, 120, 71, 40, 89, 94, 246, 123, 99,
+			123, 215, 22, 117, 88, 50, 170, 244, 222, 83, 155, 201, 204, 244,
+			38, 2, 68, 50, 141, 221, 75, 227, 96, 144, 82, 174, 168, 108,
+			9, 123, 25, 190, 232, 170, 236, 62, 154, 80, 94, 133, 21, 181,
+			191, 239, 153, 174, 73, 215, 173, 53, 153, 105, 188, 115, 147, 153,
+			154, 132, 59, 244, 206, 19, 238, 244, 152, 44, 78, 77, 130, 197,
+			58, 77, 211, 96, 142, 118, 236, 224, 18, 59, 76, 15, 78, 207,
+			95, 88, 152, 159, 75, 205, 229, 158, 147, 58, 154, 123, 38, 249,
+			216, 94, 105, 101, 216, 241, 58, 179, 116, 57, 84, 65, 102, 32,
+			83, 249, 129, 244, 108, 253, 61, 76, 155, 66, 218, 211, 143, 80,
+			65, 237, 161, 182, 191, 89, 185, 230, 109, 43, 33, 174, 160, 104,
+			54, 145, 91, 204, 213, 80, 54, 145, 59, 85, 67, 79, 61, 246,
+			76, 114, 110, 15, 189, 145, 77, 214, 97, 95, 40, 235, 73, 40,
+			91, 133, 97, 222, 239, 32, 218, 161, 142, 187, 89, 247, 154, 151,
+			127, 108, 211, 171, 108, 215, 101, 225, 161, 122, 44, 140, 114, 175,
+			139, 198, 94, 46, 190, 87, 156, 147, 192, 169, 139, 207, 36, 179,
+			244, 96, 221, 19, 118, 168, 201, 99, 245, 14, 109, 250, 231, 130,
+			39, 142, 109, 2, 90, 2, 156, 64, 250, 223, 198, 104, 139, 212,
+			233, 189, 245, 141, 162, 202, 20, 248, 156, 71, 126, 88, 155, 207,
+			17, 88, 69, 172, 78, 222, 45, 101, 53, 55, 29, 82, 235, 85,
+			230, 20, 153, 199, 204, 217, 61, 27, 76, 160, 207, 171, 68, 100,
+			199, 105, 143, 186, 166, 88, 90, 223, 244, 171, 75, 87, 189, 37,
+			161, 60, 122, 121, 152, 90, 137, 76, 167, 250, 245, 194, 166, 95,
+			61, 237, 165, 224, 39, 182, 72, 219, 171, 170, 175, 198, 168, 49,
+			6, 210, 238, 238, 157, 228, 106, 174, 140, 235, 63, 180, 109, 99,
+			91, 53, 90, 192, 102, 105, 139, 54, 185, 12, 242, 199, 181, 78,
+			13, 237, 129, 83, 93, 160, 131, 65, 79, 166, 57, 31, 130, 216,
+			24, 101, 193, 26, 213, 55, 164, 32, 79, 19, 153, 14, 243, 139,
+			82, 79, 242, 161, 69, 144, 184, 173, 69, 48, 248, 24, 109, 171,
+			233, 17, 227, 116, 32, 151, 186, 176, 48, 43, 164, 81, 125, 99,
+			204, 118, 218, 44, 149, 151, 44, 196, 224, 140, 90, 100, 14, 158,
+			167, 205, 225, 14, 177, 253, 180, 79, 239, 196, 160, 60, 212, 32,
+			235, 167, 251, 162, 26, 209, 82, 38, 181, 48, 159, 201, 9, 125,
+			236, 212, 236, 51, 201, 116, 109, 146, 153, 232, 44, 173, 119, 61,
+			161, 135, 196, 159, 184, 97, 70, 88, 204, 241, 159, 176, 105, 139,
+			90, 43, 211, 16, 106, 178, 238, 28, 127, 128, 246, 122, 215, 85,
+			134, 218, 37, 121, 110, 223, 168, 120, 43, 133, 235, 158, 124, 202,
+			107, 204, 244, 152, 223, 225, 248, 191, 160, 126, 101, 199, 104, 151,
+			124, 9, 88, 210, 51, 32, 188, 128, 153, 252, 77, 241, 70, 174,
+			83, 33, 6, 84, 85, 191, 92, 209, 41, 88, 155, 84, 89, 182,
+			92, 169, 214, 51, 225, 141, 61, 23, 19, 222, 203, 180, 83, 223,
+			168, 174, 86, 10, 249, 37, 25, 108, 83, 101, 55, 140, 166, 94,
+			138, 240, 104, 252, 108, 165, 144, 191, 88, 240, 182, 36, 152, 233,
+			80, 88, 68, 177, 226, 98, 150, 238, 171, 233, 183, 102, 187, 218,
+			249, 235, 101, 176, 212, 35, 152, 233, 142, 240, 197, 12, 236, 139,
+			233, 64, 169, 92, 90, 82, 79, 43, 59, 49, 39, 110, 141, 185,
+			175, 84, 46, 93, 144, 223, 215, 98, 31, 167, 157, 21, 239, 90,
+			1, 110, 82, 55, 43, 197, 37, 233, 114, 4, 106, 67, 99, 166,
+			67, 255, 180, 88, 41, 202, 84, 214, 66, 194, 200, 119, 12, 117,
+			41, 226, 149, 170, 149, 109, 184, 6, 165, 242, 102, 68, 254, 10,
+			163, 144, 18, 191, 45, 86, 138, 206, 139, 105, 107, 148, 121, 236,
+			46, 170, 135, 101, 233, 250, 146, 91, 173, 170, 180, 102, 102, 209,
+			191, 40, 89, 173, 86, 194, 181, 182, 101, 45, 28, 169, 117, 89,
+			212, 146, 217, 202, 250, 235, 238, 4, 170, 181, 190, 186, 10, 128,
+			248, 105, 240, 95, 137, 89, 10, 146, 67, 117, 151, 194, 41, 106,
+			137, 3, 154, 58, 229, 29, 173, 55, 73, 228, 215, 26, 130, 179,
+			18, 124, 195, 94, 64, 91, 117, 14, 21, 56, 170, 201, 204, 96,
+			123, 188, 131, 183, 232, 234, 162, 76, 156, 178, 154, 21, 199, 229,
+			215, 160, 227, 102, 154, 100, 153, 172, 210, 69, 99, 165, 114, 213,
+			243, 213, 237, 148, 4, 88, 129, 238, 147, 193, 128, 189, 165, 66,
+			105, 201, 221, 172, 150, 85, 116, 21, 173, 204, 78, 238, 209, 143,
+			100, 168, 250, 197, 130, 95, 184, 90, 40, 22, 170, 219, 153, 110,
+			133, 49, 93, 10, 87, 24, 188, 72, 155, 66, 29, 103, 3, 180,
+			87, 139, 50, 56, 250, 237, 56, 102, 202, 195, 19, 18, 167, 43,
+			113, 196, 76, 231, 228, 251, 124, 27, 109, 154, 158, 159, 203, 101,
+			210, 167, 23, 115, 243, 153, 118, 50, 248, 34, 218, 83, 159, 16,
+			54, 68, 15, 39, 23, 115, 243, 66, 115, 156, 77, 229, 82, 75,
+			23, 211, 217, 244, 233, 244, 108, 58, 87, 43, 163, 41, 181, 213,
+			185, 7, 137, 150, 179, 231, 230, 47, 205, 181, 227, 31, 194, 99,
+			254, 175, 188, 11, 209, 56, 139, 197, 27, 126, 193, 66, 244, 253,
+			242, 57, 63, 254, 255, 252, 231, 252, 23, 222, 242, 57, 95, 47,
+			54, 233, 220, 12, 94, 61, 250, 69, 41, 120, 180, 111, 52, 143,
+			246, 77, 193, 163, 125, 83, 96, 164, 221, 18, 24, 105, 107, 123,
+			109, 28, 216, 107, 19, 70, 218, 27, 94, 12, 127, 90, 140, 116,
+			52, 184, 244, 239, 144, 206, 82, 117, 20, 57, 95, 85, 137, 95,
+			202, 27, 218, 178, 177, 178, 234, 150, 10, 79, 185, 218, 122, 78,
+			61, 152, 71, 28, 243, 111, 219, 215, 90, 205, 116, 233, 100, 45,
+			150, 184, 63, 78, 161, 175, 183, 29, 230, 121, 107, 109, 123, 44,
+			95, 246, 252, 49, 253, 197, 216, 154, 123, 205, 27, 211, 140, 219,
+			53, 38, 115, 56, 181, 148, 182, 40, 239, 195, 3, 97, 139, 114,
+			0, 141, 69, 249, 193, 221, 45, 202, 55, 76, 240, 179, 224, 49,
+			245, 96, 228, 49, 245, 96, 196, 162, 252, 96, 123, 7, 132, 26,
+			132, 199, 212, 67, 120, 198, 57, 192, 103, 164, 162, 187, 39, 90,
+			20, 19, 181, 195, 102, 225, 135, 34, 102, 225, 135, 34, 102, 225,
+			135, 156, 233, 168, 89, 248, 161, 254, 211, 42, 82, 61, 102, 228,
+			48, 238, 118, 146, 181, 70, 196, 170, 193, 81, 94, 240, 148, 129,
+			239, 213, 66, 105, 149, 111, 65, 252, 206, 42, 248, 110, 195, 224,
+			40, 75, 223, 136, 149, 246, 225, 136, 149, 246, 97, 99, 108, 140,
+			9, 35, 135, 59, 187, 232, 73, 109, 117, 125, 4, 247, 59, 163,
+			124, 49, 51, 203, 55, 202, 5, 21, 60, 171, 172, 220, 86, 77,
+			180, 207, 98, 121, 181, 204, 11, 235, 238, 170, 23, 73, 241, 112,
+			36, 146, 226, 225, 72, 99, 79, 200, 130, 250, 72, 159, 67, 255,
+			15, 44, 237, 62, 39, 26, 30, 71, 206, 255, 138, 35, 126, 245,
+			222, 134, 207, 221, 98, 89, 197, 234, 82, 75, 21, 162, 118, 4,
+			65, 99, 194, 65, 85, 76, 16, 149, 91, 76, 231, 157, 83, 242,
+			92, 109, 228, 113, 237, 172, 79, 249, 240, 216, 24, 215, 33, 89,
+			74, 229, 234, 152, 137, 7, 114, 138, 15, 202, 231, 177, 193, 80,
+			92, 51, 45, 203, 64, 20, 156, 203, 229, 22, 38, 86, 51, 11,
+			211, 32, 196, 124, 202, 197, 127, 27, 94, 133, 135, 66, 188, 140,
+			155, 53, 104, 34, 166, 114, 29, 115, 68, 70, 229, 73, 166, 23,
+			76, 26, 50, 149, 224, 166, 160, 112, 129, 103, 190, 119, 189, 234,
+			149, 132, 22, 10, 22, 195, 144, 56, 78, 90, 212, 121, 220, 223,
+			246, 171, 222, 250, 248, 200, 45, 13, 99, 39, 18, 29, 198, 48,
+			246, 24, 62, 30, 54, 140, 5, 240, 65, 106, 89, 72, 8, 152,
+			251, 241, 35, 144, 201, 77, 229, 9, 145, 130, 90, 70, 13, 216,
+			51, 151, 14, 130, 149, 123, 127, 188, 139, 158, 162, 182, 128, 196,
+			250, 60, 105, 221, 229, 220, 99, 236, 134, 188, 210, 230, 250, 45,
+			140, 135, 192, 18, 82, 173, 207, 147, 202, 140, 26, 41, 51, 234,
+			147, 131, 135, 33, 46, 41, 146, 107, 244, 65, 171, 5, 162, 158,
+			25, 250, 248, 186, 231, 150, 66, 110, 216, 66, 198, 135, 80, 34,
+			248, 38, 17, 192, 152, 145, 7, 155, 154, 233, 113, 133, 18, 51,
+			242, 176, 213, 6, 209, 110, 118, 69, 105, 194, 151, 105, 36, 130,
+			144, 135, 173, 16, 44, 176, 180, 180, 210, 71, 20, 82, 194, 200,
+			11, 173, 54, 240, 231, 223, 21, 105, 189, 168, 123, 1, 70, 177,
+			168, 94, 24, 106, 65, 108, 19, 47, 108, 105, 133, 240, 107, 72,
+			172, 171, 25, 252, 40, 113, 198, 101, 16, 162, 219, 27, 177, 227,
+			122, 196, 4, 75, 102, 226, 221, 244, 4, 80, 43, 237, 162, 173,
+			35, 183, 109, 233, 37, 73, 82, 230, 208, 22, 15, 96, 204, 200,
+			153, 195, 119, 209, 187, 21, 90, 196, 200, 57, 171, 19, 114, 222,
+			4, 76, 40, 248, 60, 239, 137, 101, 224, 86, 189, 124, 128, 11,
+			76, 159, 173, 214, 0, 198, 210, 248, 121, 92, 225, 194, 140, 156,
+			183, 218, 156, 131, 181, 184, 100, 156, 226, 157, 248, 68, 227, 231,
+			173, 16, 44, 16, 180, 180, 130, 168, 135, 14, 207, 97, 230, 28,
+			168, 191, 133, 104, 38, 134, 108, 192, 231, 34, 54, 224, 115, 17,
+			27, 240, 185, 246, 14, 21, 71, 30, 49, 178, 128, 59, 157, 253,
+			60, 27, 10, 183, 191, 11, 86, 20, 49, 23, 70, 17, 115, 97,
+			164, 204, 133, 15, 107, 67, 238, 12, 238, 115, 122, 234, 175, 77,
+			109, 216, 108, 139, 90, 109, 33, 19, 232, 76, 123, 87, 200, 4,
+			58, 179, 175, 151, 254, 56, 82, 70, 206, 100, 17, 51, 103, 155,
+			139, 227, 39, 132, 128, 113, 75, 79, 214, 226, 229, 233, 21, 190,
+			229, 113, 113, 104, 213, 90, 197, 213, 109, 245, 35, 149, 53, 225,
+			179, 188, 87, 21, 26, 126, 73, 167, 102, 18, 245, 193, 84, 156,
+			87, 92, 136, 242, 88, 93, 115, 75, 145, 192, 114, 17, 131, 233,
+			197, 136, 193, 244, 162, 225, 171, 216, 68, 22, 219, 59, 148, 221,
+			190, 197, 200, 37, 188, 207, 57, 204, 79, 67, 148, 94, 239, 250,
+			70, 209, 45, 185, 145, 224, 142, 53, 236, 176, 66, 57, 136, 164,
+			185, 243, 37, 149, 28, 72, 154, 59, 95, 234, 238, 161, 119, 105,
+			123, 230, 203, 184, 223, 217, 183, 203, 66, 210, 118, 201, 182, 168,
+			214, 30, 178, 89, 190, 220, 209, 19, 178, 89, 190, 220, 231, 208,
+			47, 34, 8, 169, 111, 185, 13, 55, 144, 243, 73, 196, 47, 121,
+			197, 226, 216, 147, 165, 242, 86, 73, 70, 30, 247, 35, 138, 108,
+			40, 164, 105, 33, 8, 253, 31, 13, 181, 125, 7, 202, 155, 12,
+			148, 35, 131, 135, 130, 2, 39, 155, 220, 53, 15, 134, 142, 172,
+			26, 137, 241, 29, 108, 251, 42, 223, 167, 152, 71, 110, 162, 93,
+			6, 239, 135, 76, 63, 43, 114, 7, 129, 104, 253, 18, 20, 59,
+			8, 168, 168, 47, 195, 47, 23, 59, 72, 148, 143, 42, 144, 251,
+			94, 242, 8, 195, 14, 242, 50, 181, 131, 96, 185, 131, 20, 159,
+			203, 14, 130, 213, 14, 82, 84, 59, 8, 86, 59, 72, 113, 240,
+			48, 29, 81, 184, 17, 35, 37, 171, 19, 210, 88, 105, 242, 234,
+			201, 36, 172, 118, 142, 146, 146, 73, 88, 237, 28, 165, 14, 38,
+			67, 145, 203, 157, 99, 195, 106, 115, 14, 212, 160, 170, 39, 146,
+			176, 218, 51, 54, 172, 16, 44, 190, 111, 105, 5, 217, 129, 69,
+			159, 171, 187, 199, 185, 214, 241, 240, 155, 117, 156, 107, 82, 85,
+			179, 27, 194, 92, 147, 170, 90, 57, 16, 229, 154, 84, 219, 59,
+			232, 136, 138, 113, 77, 174, 225, 78, 103, 160, 174, 68, 138, 34,
+			21, 2, 233, 154, 65, 42, 250, 126, 77, 9, 36, 8, 35, 77,
+			174, 117, 48, 58, 161, 162, 72, 147, 235, 120, 159, 51, 184, 199,
+			114, 140, 162, 22, 139, 236, 186, 65, 45, 136, 186, 174, 86, 35,
+			196, 146, 38, 215, 187, 123, 64, 214, 1, 240, 20, 118, 156, 158,
+			250, 179, 72, 125, 66, 108, 81, 171, 77, 67, 136, 145, 167, 218,
+			187, 53, 36, 48, 244, 246, 209, 55, 91, 50, 76, 219, 107, 81,
+			195, 47, 98, 228, 188, 202, 226, 211, 145, 248, 186, 38, 183, 139,
+			10, 174, 243, 195, 92, 113, 211, 107, 222, 242, 147, 92, 156, 175,
+			163, 7, 241, 141, 240, 67, 67, 126, 189, 80, 82, 107, 176, 90,
+			230, 190, 39, 45, 95, 245, 11, 36, 79, 207, 8, 76, 233, 21,
+			190, 93, 222, 172, 200, 163, 178, 140, 119, 84, 45, 243, 101, 183,
+			88, 228, 235, 155, 197, 106, 97, 163, 24, 144, 202, 11, 37, 161,
+			76, 46, 131, 106, 11, 38, 178, 230, 20, 38, 154, 205, 255, 152,
+			95, 117, 87, 11, 165, 213, 31, 203, 123, 215, 110, 142, 68, 90,
+			2, 46, 232, 2, 159, 6, 97, 44, 125, 49, 5, 195, 15, 2,
+			210, 152, 87, 70, 184, 226, 87, 189, 234, 150, 231, 149, 164, 115,
+			143, 110, 140, 26, 58, 198, 121, 78, 118, 12, 206, 44, 161, 246,
+			84, 26, 73, 153, 225, 79, 116, 70, 204, 76, 69, 29, 21, 29,
+			150, 222, 63, 203, 134, 141, 99, 234, 199, 219, 98, 231, 110, 57,
+			140, 79, 4, 113, 246, 94, 139, 18, 237, 16, 27, 140, 196, 153,
+			253, 19, 8, 255, 164, 202, 65, 66, 32, 54, 152, 42, 184, 71,
+			199, 225, 123, 195, 30, 241, 231, 149, 209, 115, 40, 246, 222, 27,
+			80, 36, 246, 222, 27, 116, 252, 121, 25, 123, 239, 13, 72, 41,
+			11, 16, 98, 255, 141, 8, 207, 56, 3, 117, 207, 155, 81, 196,
+			40, 156, 211, 69, 70, 214, 127, 35, 82, 145, 120, 100, 92, 253,
+			55, 162, 222, 62, 13, 38, 4, 232, 76, 67, 248, 18, 19, 83,
+			255, 141, 168, 255, 52, 108, 167, 4, 99, 102, 189, 25, 221, 98,
+			1, 71, 219, 199, 49, 248, 68, 183, 47, 104, 127, 51, 106, 100,
+			26, 36, 2, 236, 238, 1, 221, 148, 88, 13, 204, 126, 155, 76,
+			219, 50, 26, 85, 89, 180, 133, 248, 46, 71, 22, 64, 6, 145,
+			205, 222, 134, 226, 77, 16, 90, 140, 200, 200, 102, 63, 131, 172,
+			190, 59, 219, 13, 218, 104, 92, 126, 140, 224, 235, 174, 160, 0,
+			139, 130, 125, 189, 144, 134, 146, 200, 200, 101, 111, 71, 86, 11,
+			216, 214, 107, 26, 205, 101, 83, 161, 88, 132, 4, 88, 197, 109,
+			185, 38, 68, 203, 158, 76, 144, 89, 6, 127, 219, 97, 127, 36,
+			212, 26, 146, 200, 18, 65, 1, 22, 5, 77, 205, 166, 51, 152,
+			89, 63, 143, 172, 102, 231, 158, 221, 91, 131, 134, 10, 165, 170,
+			183, 234, 85, 162, 232, 5, 173, 63, 143, 172, 120, 80, 0, 232,
+			104, 19, 108, 156, 68, 198, 45, 123, 167, 64, 127, 247, 45, 208,
+			203, 23, 218, 40, 118, 2, 41, 113, 66, 216, 9, 134, 28, 57,
+			77, 224, 81, 74, 100, 148, 178, 119, 9, 86, 13, 223, 2, 187,
+			144, 151, 81, 220, 22, 228, 196, 9, 49, 70, 232, 73, 239, 18,
+			140, 209, 184, 99, 204, 122, 207, 237, 224, 206, 187, 85, 47, 138,
+			59, 134, 224, 211, 0, 119, 12, 139, 130, 166, 102, 200, 237, 67,
+			100, 72, 178, 95, 20, 92, 25, 186, 5, 238, 197, 204, 108, 20,
+			181, 45, 115, 240, 4, 44, 177, 177, 40, 160, 77, 180, 25, 22,
+			18, 97, 214, 47, 33, 124, 74, 45, 3, 98, 3, 104, 107, 16,
+			9, 48, 222, 164, 65, 168, 220, 170, 3, 113, 146, 132, 0, 219,
+			79, 170, 69, 170, 114, 22, 252, 18, 234, 120, 128, 190, 25, 1,
+			114, 139, 89, 191, 138, 240, 97, 231, 85, 200, 172, 34, 29, 149,
+			211, 116, 66, 57, 15, 156, 226, 5, 143, 159, 222, 92, 229, 229,
+			10, 79, 149, 214, 132, 240, 213, 217, 192, 33, 221, 158, 78, 152,
+			30, 4, 46, 187, 186, 13, 105, 237, 7, 5, 230, 177, 149, 114,
+			121, 80, 169, 47, 229, 138, 44, 27, 172, 241, 234, 80, 84, 91,
+			49, 32, 74, 203, 1, 49, 172, 191, 138, 26, 15, 104, 144, 8,
+			240, 208, 32, 168, 9, 4, 199, 152, 253, 65, 132, 127, 13, 89,
+			206, 65, 158, 20, 242, 185, 224, 87, 43, 110, 224, 233, 188, 67,
+			202, 196, 44, 102, 125, 80, 7, 238, 37, 16, 100, 234, 131, 58,
+			112, 47, 129, 113, 254, 160, 14, 220, 75, 32, 200, 212, 7, 81,
+			255, 128, 20, 220, 16, 100, 234, 131, 104, 255, 175, 169, 20, 12,
+			68, 5, 153, 250, 16, 178, 207, 208, 117, 16, 75, 8, 114, 241,
+			124, 10, 17, 231, 37, 92, 90, 26, 69, 201, 128, 155, 54, 8,
+			186, 167, 220, 84, 202, 21, 152, 36, 254, 230, 198, 134, 56, 213,
+			152, 13, 87, 190, 98, 239, 238, 73, 37, 229, 24, 130, 28, 61,
+			241, 22, 181, 244, 33, 136, 239, 111, 33, 171, 255, 185, 200, 49,
+			25, 177, 247, 183, 144, 213, 19, 20, 96, 81, 208, 231, 128, 131,
+			57, 145, 254, 199, 31, 69, 86, 7, 120, 87, 153, 249, 161, 189,
+			104, 181, 32, 219, 4, 247, 94, 125, 222, 0, 127, 155, 66, 117,
+			200, 143, 102, 226, 212, 77, 32, 137, 178, 57, 40, 192, 162, 160,
+			173, 29, 82, 84, 17, 153, 55, 237, 99, 200, 218, 7, 249, 227,
+			77, 155, 234, 13, 11, 52, 149, 181, 66, 62, 239, 149, 66, 56,
+			85, 250, 30, 22, 20, 0, 14, 21, 63, 144, 72, 23, 220, 223,
+			69, 86, 23, 248, 81, 71, 22, 43, 132, 222, 219, 49, 8, 6,
+			145, 88, 110, 191, 139, 172, 182, 160, 0, 139, 2, 157, 62, 157,
+			192, 49, 210, 250, 56, 178, 90, 157, 159, 64, 58, 140, 36, 119,
+			85, 100, 49, 53, 3, 214, 61, 183, 36, 148, 145, 66, 85, 206,
+			132, 13, 153, 30, 36, 154, 105, 77, 186, 169, 211, 157, 81, 77,
+			121, 26, 78, 119, 58, 74, 238, 154, 199, 151, 215, 10, 197, 188,
+			138, 134, 237, 86, 170, 133, 229, 205, 162, 91, 9, 135, 142, 211,
+			196, 90, 144, 246, 199, 106, 12, 10, 176, 40, 104, 110, 161, 239,
+			214, 212, 199, 152, 245, 73, 100, 117, 58, 255, 3, 210, 62, 216,
+			145, 233, 11, 60, 130, 157, 234, 170, 199, 165, 113, 68, 48, 214,
+			193, 139, 185, 88, 249, 65, 28, 13, 117, 229, 47, 99, 17, 200,
+			135, 13, 37, 88, 192, 122, 0, 146, 114, 138, 238, 248, 155, 87,
+			101, 61, 213, 98, 197, 19, 59, 200, 114, 40, 132, 35, 145, 199,
+			98, 65, 98, 107, 80, 128, 69, 65, 7, 83, 226, 18, 18, 4,
+			225, 253, 106, 1, 219, 22, 128, 122, 181, 219, 240, 171, 89, 237,
+			54, 36, 15, 50, 171, 221, 134, 212, 65, 253, 3, 244, 173, 82,
+			56, 198, 153, 253, 52, 194, 159, 67, 150, 16, 143, 33, 163, 30,
+			29, 150, 213, 112, 229, 170, 87, 44, 151, 86, 197, 100, 220, 145,
+			114, 150, 39, 245, 42, 209, 161, 141, 203, 213, 53, 57, 205, 174,
+			212, 88, 162, 95, 1, 159, 49, 193, 30, 248, 25, 146, 172, 156,
+			75, 102, 83, 28, 108, 215, 141, 0, 139, 135, 243, 15, 17, 8,
+			227, 246, 52, 106, 212, 125, 136, 67, 254, 33, 35, 177, 226, 9,
+			209, 135, 253, 159, 51, 18, 11, 114, 216, 90, 159, 69, 42, 96,
+			162, 40, 128, 77, 225, 143, 144, 61, 70, 231, 69, 183, 73, 3,
+			179, 191, 136, 240, 215, 16, 113, 94, 200, 181, 49, 104, 224, 71,
+			87, 45, 75, 145, 2, 226, 94, 30, 76, 118, 15, 119, 1, 52,
+			65, 30, 188, 47, 34, 234, 208, 140, 152, 101, 164, 65, 180, 240,
+			101, 100, 253, 57, 138, 57, 143, 240, 249, 146, 23, 250, 80, 218,
+			83, 235, 104, 184, 208, 208, 94, 10, 221, 113, 53, 47, 4, 78,
+			72, 158, 211, 204, 232, 136, 232, 150, 40, 192, 144, 11, 199, 222,
+			231, 236, 83, 121, 72, 245, 196, 146, 141, 140, 83, 200, 77, 165,
+			170, 198, 160, 110, 115, 184, 8, 137, 162, 22, 22, 46, 34, 162,
+			168, 187, 135, 158, 48, 109, 32, 102, 253, 25, 178, 251, 247, 188,
+			54, 170, 211, 158, 208, 181, 255, 44, 218, 30, 2, 84, 45, 61,
+			225, 34, 34, 138, 250, 28, 218, 42, 25, 39, 122, 244, 85, 100,
+			113, 221, 105, 220, 96, 65, 65, 115, 80, 0, 89, 120, 90, 58,
+			131, 2, 200, 195, 211, 213, 31, 20, 64, 38, 158, 3, 7, 213,
+			114, 73, 64, 2, 157, 35, 106, 246, 36, 100, 98, 30, 173, 115,
+			39, 32, 129, 78, 39, 215, 32, 36, 208, 57, 124, 23, 60, 42,
+			17, 130, 152, 253, 13, 132, 191, 133, 136, 243, 130, 250, 243, 164,
+			80, 170, 222, 198, 52, 57, 174, 167, 9, 130, 116, 54, 180, 143,
+			158, 132, 222, 194, 102, 246, 215, 200, 234, 117, 70, 248, 133, 66,
+			169, 176, 174, 247, 177, 29, 203, 79, 11, 110, 45, 37, 84, 42,
+			191, 191, 54, 162, 78, 165, 242, 251, 107, 68, 59, 131, 2, 200,
+			92, 211, 179, 207, 180, 134, 152, 245, 77, 213, 154, 123, 253, 206,
+			90, 19, 163, 249, 205, 112, 107, 8, 144, 133, 90, 19, 35, 249,
+			77, 209, 154, 228, 122, 35, 179, 254, 22, 225, 187, 20, 95, 27,
+			109, 0, 181, 84, 106, 68, 2, 100, 90, 225, 105, 36, 2, 60,
+			116, 88, 113, 29, 51, 251, 239, 17, 254, 159, 119, 231, 186, 95,
+			173, 220, 201, 226, 20, 253, 254, 123, 193, 245, 135, 128, 15, 144,
+			12, 237, 219, 200, 234, 134, 240, 20, 171, 222, 245, 29, 253, 7,
+			174, 12, 251, 35, 114, 223, 95, 119, 171, 203, 107, 134, 21, 50,
+			59, 218, 183, 117, 170, 56, 162, 146, 237, 125, 91, 231, 80, 36,
+			42, 217, 222, 183, 17, 68, 35, 18, 172, 160, 144, 164, 69, 179,
+			130, 202, 156, 45, 154, 21, 20, 114, 182, 24, 86, 80, 200, 217,
+			114, 232, 176, 18, 84, 132, 217, 223, 65, 248, 117, 120, 87, 65,
+			5, 174, 169, 183, 230, 197, 9, 205, 11, 177, 189, 127, 71, 8,
+			170, 57, 224, 5, 156, 50, 191, 139, 172, 255, 13, 197, 156, 135,
+			120, 234, 26, 100, 12, 1, 110, 40, 175, 0, 238, 70, 130, 110,
+			223, 74, 72, 201, 115, 231, 119, 81, 227, 62, 122, 22, 4, 136,
+			58, 121, 254, 51, 178, 143, 58, 39, 140, 198, 38, 61, 38, 116,
+			35, 183, 208, 221, 164, 176, 208, 167, 208, 127, 70, 246, 161, 112,
+			17, 22, 69, 119, 29, 161, 19, 166, 57, 196, 172, 255, 134, 236,
+			118, 103, 63, 207, 129, 102, 38, 55, 223, 154, 216, 225, 97, 180,
+			72, 126, 209, 20, 46, 194, 162, 8, 162, 0, 105, 180, 24, 82,
+			156, 244, 56, 71, 185, 244, 155, 128, 107, 23, 239, 26, 196, 30,
+			218, 14, 167, 104, 95, 119, 243, 94, 4, 191, 160, 232, 123, 200,
+			238, 8, 23, 1, 182, 174, 110, 37, 246, 224, 126, 228, 251, 200,
+			26, 210, 108, 4, 41, 247, 125, 163, 224, 169, 124, 132, 223, 71,
+			157, 131, 65, 1, 164, 59, 57, 114, 148, 110, 169, 145, 68, 204,
+			254, 239, 200, 122, 21, 142, 57, 234, 254, 43, 152, 199, 97, 157,
+			70, 236, 192, 106, 226, 168, 155, 244, 149, 205, 162, 80, 108, 165,
+			119, 117, 185, 8, 41, 169, 2, 55, 142, 91, 15, 185, 224, 223,
+			127, 71, 141, 125, 52, 173, 152, 5, 114, 237, 7, 200, 30, 118,
+			238, 55, 67, 46, 16, 135, 241, 222, 230, 160, 75, 177, 246, 3,
+			100, 31, 14, 23, 97, 81, 116, 116, 136, 142, 154, 6, 17, 179,
+			94, 129, 237, 3, 112, 31, 13, 234, 184, 47, 198, 60, 212, 94,
+			4, 39, 146, 213, 251, 194, 69, 88, 20, 13, 236, 167, 179, 6,
+			39, 102, 214, 43, 177, 221, 231, 156, 186, 109, 118, 110, 173, 149,
+			97, 186, 5, 113, 14, 130, 6, 4, 137, 175, 196, 118, 87, 184,
+			8, 90, 216, 215, 107, 102, 1, 98, 214, 171, 177, 117, 143, 25,
+			99, 100, 67, 65, 87, 80, 0, 53, 186, 143, 6, 5, 68, 20,
+			140, 220, 77, 47, 41, 20, 152, 89, 63, 142, 173, 126, 231, 44,
+			4, 150, 17, 212, 150, 220, 245, 208, 195, 132, 210, 75, 43, 158,
+			140, 11, 173, 131, 111, 135, 162, 20, 20, 162, 41, 211, 116, 75,
+			66, 113, 254, 113, 108, 133, 10, 144, 40, 104, 234, 9, 10, 136,
+			40, 232, 115, 232, 188, 162, 133, 48, 235, 39, 176, 229, 56, 47,
+			188, 125, 6, 154, 240, 78, 1, 65, 33, 26, 72, 12, 48, 134,
+			10, 144, 40, 104, 234, 14, 10, 160, 77, 48, 26, 17, 162, 183,
+			137, 89, 255, 1, 227, 163, 74, 184, 54, 217, 0, 234, 189, 191,
+			9, 9, 208, 236, 253, 77, 68, 128, 135, 143, 40, 209, 107, 49,
+			251, 13, 24, 191, 99, 119, 209, 11, 225, 242, 238, 96, 27, 18,
+			103, 147, 55, 96, 234, 208, 23, 1, 123, 44, 33, 122, 223, 132,
+			173, 159, 195, 49, 231, 12, 151, 190, 70, 128, 182, 44, 47, 116,
+			93, 217, 64, 104, 71, 226, 110, 165, 82, 184, 182, 235, 145, 249,
+			94, 205, 22, 11, 132, 240, 155, 112, 99, 39, 157, 129, 201, 44,
+			173, 125, 172, 183, 96, 123, 208, 153, 50, 43, 18, 208, 75, 183,
+			169, 219, 90, 140, 150, 146, 192, 111, 193, 246, 254, 112, 17, 22,
+			69, 252, 16, 125, 196, 180, 133, 152, 245, 54, 108, 119, 57, 227,
+			124, 174, 172, 91, 208, 209, 43, 170, 238, 147, 94, 41, 210, 67,
+			211, 171, 16, 82, 36, 81, 180, 133, 139, 176, 40, 98, 157, 244,
+			148, 105, 7, 51, 235, 103, 176, 189, 207, 25, 214, 34, 89, 198,
+			130, 147, 247, 140, 183, 104, 65, 16, 249, 51, 216, 102, 225, 34,
+			192, 215, 221, 19, 234, 9, 97, 214, 219, 133, 88, 25, 215, 45,
+			184, 197, 162, 58, 130, 22, 54, 220, 82, 213, 191, 101, 59, 98,
+			134, 190, 61, 16, 53, 150, 186, 16, 124, 187, 16, 53, 114, 217,
+			91, 98, 108, 126, 94, 167, 133, 0, 216, 134, 130, 214, 160, 0,
+			137, 130, 54, 39, 40, 32, 162, 96, 255, 1, 53, 205, 155, 153,
+			245, 206, 96, 154, 55, 219, 0, 234, 105, 222, 140, 4, 104, 166,
+			121, 51, 17, 224, 225, 35, 144, 153, 139, 224, 22, 102, 191, 27,
+			227, 247, 98, 203, 185, 215, 132, 131, 172, 185, 207, 1, 126, 138,
+			243, 74, 224, 248, 101, 166, 188, 66, 218, 98, 49, 235, 221, 216,
+			28, 66, 91, 98, 2, 52, 135, 208, 22, 36, 64, 166, 47, 214,
+			91, 136, 0, 7, 14, 168, 3, 92, 75, 66, 144, 112, 240, 189,
+			88, 31, 224, 90, 224, 0, 247, 30, 108, 159, 161, 175, 195, 50,
+			189, 206, 7, 112, 195, 255, 136, 145, 243, 44, 226, 73, 190, 86,
+			88, 93, 83, 118, 123, 65, 100, 22, 99, 238, 229, 86, 170, 198,
+			82, 160, 188, 82, 221, 18, 146, 206, 93, 89, 241, 150, 205, 117,
+			157, 145, 108, 119, 102, 1, 181, 86, 107, 1, 101, 188, 10, 130,
+			183, 170, 192, 192, 120, 199, 163, 74, 56, 47, 102, 240, 78, 101,
+			74, 171, 193, 99, 85, 221, 87, 151, 169, 32, 85, 207, 7, 112,
+			162, 11, 94, 93, 172, 56, 179, 63, 136, 241, 127, 196, 242, 213,
+			197, 130, 87, 23, 85, 112, 137, 90, 22, 72, 152, 223, 196, 248,
+			119, 49, 113, 206, 238, 8, 193, 191, 51, 27, 123, 36, 208, 205,
+			94, 7, 25, 41, 96, 126, 19, 199, 123, 225, 86, 78, 137, 151,
+			143, 96, 107, 248, 206, 111, 229, 180, 84, 249, 8, 182, 14, 7,
+			5, 144, 184, 228, 232, 16, 152, 173, 40, 137, 242, 219, 216, 234,
+			212, 111, 196, 1, 201, 53, 79, 206, 26, 1, 146, 31, 180, 6,
+			5, 144, 154, 164, 131, 209, 41, 133, 17, 82, 135, 88, 109, 218,
+			4, 42, 130, 177, 246, 229, 89, 35, 193, 50, 1, 73, 168, 0,
+			176, 180, 180, 210, 15, 97, 149, 58, 201, 250, 20, 198, 204, 121,
+			39, 174, 255, 204, 101, 90, 25, 229, 238, 147, 46, 15, 50, 57,
+			141, 203, 140, 179, 201, 133, 180, 20, 148, 110, 113, 203, 221, 246,
+			121, 197, 171, 110, 86, 74, 60, 236, 173, 166, 66, 186, 194, 70,
+			41, 141, 241, 79, 81, 126, 75, 7, 182, 135, 106, 29, 216, 94,
+			48, 78, 249, 185, 242, 22, 232, 175, 209, 150, 151, 151, 189, 141,
+			234, 45, 154, 244, 159, 83, 155, 63, 6, 51, 224, 5, 227, 225,
+			60, 81, 159, 138, 230, 137, 250, 148, 206, 190, 33, 69, 220, 167,
+			112, 123, 7, 108, 199, 144, 6, 234, 51, 24, 119, 58, 201, 186,
+			79, 240, 33, 198, 22, 60, 62, 148, 43, 151, 139, 254, 11, 178,
+			85, 87, 90, 131, 15, 241, 114, 133, 15, 157, 46, 22, 74, 79,
+			14, 69, 178, 51, 125, 38, 154, 157, 233, 51, 65, 150, 42, 161,
+			87, 125, 70, 204, 151, 123, 161, 117, 204, 172, 63, 196, 120, 159,
+			115, 116, 175, 59, 16, 77, 131, 105, 66, 168, 76, 127, 24, 52,
+			33, 250, 240, 135, 184, 81, 103, 190, 18, 155, 203, 31, 138, 237,
+			230, 56, 52, 65, 152, 253, 89, 140, 63, 135, 45, 231, 240, 110,
+			215, 252, 59, 91, 32, 22, 36, 48, 49, 32, 228, 51, 81, 114,
+			215, 130, 189, 231, 179, 88, 93, 254, 65, 186, 117, 235, 179, 88,
+			93, 156, 89, 152, 36, 68, 139, 251, 63, 167, 228, 174, 37, 3,
+			40, 90, 127, 36, 228, 238, 9, 32, 201, 98, 246, 231, 49, 254,
+			34, 182, 156, 35, 60, 185, 89, 45, 243, 229, 229, 33, 149, 145,
+			125, 47, 162, 44, 139, 89, 159, 15, 136, 178, 98, 2, 52, 68,
+			9, 17, 246, 121, 204, 186, 53, 72, 4, 168, 210, 159, 88, 144,
+			112, 227, 243, 216, 249, 162, 33, 74, 166, 128, 254, 130, 32, 106,
+			24, 136, 138, 49, 235, 63, 99, 44, 52, 253, 168, 109, 195, 78,
+			58, 98, 54, 84, 237, 212, 32, 18, 160, 74, 190, 109, 193, 195,
+			199, 127, 22, 187, 240, 253, 128, 214, 102, 246, 159, 96, 252, 103,
+			216, 82, 217, 93, 228, 193, 26, 12, 141, 76, 114, 235, 186, 173,
+			216, 49, 102, 253, 73, 48, 200, 54, 18, 160, 74, 28, 110, 193,
+			133, 235, 159, 224, 206, 46, 213, 61, 59, 33, 154, 233, 254, 51,
+			211, 61, 27, 186, 247, 167, 58, 135, 135, 165, 179, 100, 64, 130,
+			145, 135, 129, 178, 56, 179, 191, 138, 241, 215, 177, 229, 140, 213,
+			80, 6, 185, 77, 116, 110, 209, 221, 232, 139, 199, 152, 245, 213,
+			128, 190, 56, 18, 96, 163, 30, 141, 56, 17, 160, 202, 192, 109,
+			193, 101, 234, 87, 113, 207, 215, 13, 125, 242, 50, 245, 107, 1,
+			125, 234, 50, 245, 191, 8, 250, 238, 3, 250, 18, 204, 254, 75,
+			140, 255, 10, 91, 42, 102, 107, 16, 89, 51, 16, 170, 144, 187,
+			86, 242, 209, 16, 150, 176, 153, 245, 151, 58, 17, 139, 5, 55,
+			113, 127, 137, 85, 186, 88, 11, 110, 226, 254, 18, 223, 125, 143,
+			34, 44, 1, 173, 140, 254, 21, 38, 138, 142, 132, 164, 227, 27,
+			130, 142, 135, 128, 142, 70, 102, 255, 13, 198, 223, 194, 150, 74,
+			165, 187, 27, 29, 17, 174, 25, 106, 26, 109, 102, 253, 77, 64,
+			77, 35, 18, 160, 161, 166, 145, 8, 240, 238, 81, 69, 77, 99,
+			66, 180, 53, 246, 45, 67, 77, 163, 164, 230, 155, 130, 154, 105,
+			160, 134, 50, 235, 239, 196, 52, 61, 161, 45, 196, 164, 37, 246,
+			102, 181, 60, 230, 110, 108, 20, 183, 67, 6, 107, 250, 104, 182,
+			115, 236, 168, 5, 88, 12, 24, 19, 160, 89, 73, 20, 9, 208,
+			44, 111, 74, 4, 216, 191, 159, 62, 107, 201, 132, 114, 255, 132,
+			27, 94, 75, 144, 243, 191, 88, 98, 79, 6, 211, 66, 147, 0,
+			78, 82, 179, 171, 193, 156, 188, 202, 247, 192, 227, 228, 249, 102,
+			152, 227, 110, 41, 79, 159, 135, 161, 185, 65, 249, 188, 12, 130,
+			194, 111, 27, 63, 114, 155, 160, 154, 198, 64, 119, 14, 149, 253,
+			136, 44, 131, 162, 173, 254, 187, 24, 7, 221, 31, 228, 13, 252,
+			39, 156, 232, 52, 121, 3, 191, 139, 241, 191, 40, 45, 85, 230,
+			13, 84, 5, 227, 58, 83, 215, 247, 132, 214, 196, 119, 137, 22,
+			92, 155, 33, 74, 232, 13, 223, 139, 102, 237, 250, 94, 52, 107,
+			215, 247, 176, 50, 15, 138, 137, 61, 247, 251, 248, 182, 204, 131,
+			100, 162, 174, 239, 71, 19, 117, 125, 63, 154, 168, 235, 251, 88,
+			153, 7, 65, 162, 46, 235, 251, 88, 153, 7, 153, 172, 92, 223,
+			199, 202, 60, 40, 38, 116, 134, 103, 241, 29, 152, 7, 201, 244,
+			91, 207, 6, 237, 11, 218, 159, 213, 250, 130, 76, 191, 245, 172,
+			208, 23, 214, 116, 114, 173, 127, 195, 184, 203, 121, 156, 167, 131,
+			68, 112, 181, 137, 226, 32, 228, 103, 197, 123, 249, 166, 231, 87,
+			65, 203, 188, 32, 51, 20, 184, 249, 151, 109, 250, 96, 126, 19,
+			186, 236, 225, 238, 74, 213, 171, 72, 57, 29, 100, 88, 143, 129,
+			86, 241, 111, 1, 89, 66, 171, 248, 183, 32, 75, 21, 1, 66,
+			88, 39, 125, 185, 206, 200, 245, 10, 130, 95, 73, 44, 199, 53,
+			71, 1, 29, 204, 62, 184, 36, 91, 54, 137, 32, 189, 168, 77,
+			211, 115, 161, 79, 232, 27, 175, 32, 38, 9, 148, 208, 55, 94,
+			65, 76, 18, 51, 161, 111, 188, 130, 152, 36, 102, 66, 223, 120,
+			5, 49, 73, 204, 132, 190, 241, 10, 194, 95, 73, 136, 74, 170,
+			37, 245, 141, 255, 63, 177, 207, 128, 249, 5, 228, 247, 122, 53,
+			193, 63, 78, 110, 211, 252, 34, 6, 230, 23, 175, 14, 200, 137,
+			197, 4, 104, 200, 17, 107, 227, 213, 68, 9, 109, 153, 227, 235,
+			213, 68, 233, 100, 50, 199, 215, 171, 201, 254, 31, 55, 228, 72,
+			243, 139, 215, 8, 114, 46, 82, 108, 217, 204, 254, 41, 210, 240,
+			118, 130, 156, 115, 70, 168, 67, 132, 2, 254, 114, 25, 176, 64,
+			10, 118, 249, 90, 11, 145, 96, 181, 72, 167, 123, 37, 74, 182,
+			132, 214, 242, 83, 36, 209, 7, 43, 214, 142, 51, 251, 13, 4,
+			191, 137, 200, 21, 107, 195, 138, 85, 5, 83, 212, 178, 108, 177,
+			98, 223, 74, 48, 115, 238, 170, 183, 98, 11, 97, 146, 182, 21,
+			91, 108, 88, 181, 111, 37, 106, 22, 217, 176, 106, 223, 74, 212,
+			170, 181, 97, 213, 190, 149, 180, 119, 192, 238, 105, 139, 153, 255,
+			211, 4, 59, 206, 137, 58, 171, 54, 138, 95, 42, 252, 229, 13,
+			79, 191, 209, 15, 153, 22, 197, 114, 254, 233, 160, 69, 4, 72,
+			213, 114, 182, 97, 57, 255, 52, 233, 237, 131, 213, 106, 139, 213,
+			250, 179, 4, 119, 58, 131, 60, 235, 185, 149, 229, 53, 94, 245,
+			42, 235, 123, 118, 72, 172, 214, 159, 13, 208, 11, 154, 127, 150,
+			168, 3, 132, 13, 171, 245, 103, 73, 7, 163, 115, 20, 139, 131,
+			250, 47, 144, 134, 63, 38, 200, 121, 36, 216, 139, 185, 54, 38,
+			144, 65, 142, 11, 82, 68, 23, 246, 202, 26, 51, 121, 76, 14,
+			151, 80, 226, 126, 129, 168, 196, 172, 241, 56, 179, 223, 69, 240,
+			123, 213, 112, 197, 97, 184, 84, 129, 16, 176, 113, 49, 92, 239,
+			35, 187, 11, 88, 77, 135, 234, 89, 28, 134, 234, 125, 186, 103,
+			113, 24, 170, 247, 233, 161, 138, 195, 80, 189, 79, 12, 149, 68,
+			142, 152, 245, 203, 4, 207, 56, 188, 254, 80, 237, 64, 46, 70,
+			229, 151, 3, 228, 8, 190, 87, 163, 18, 135, 81, 249, 101, 162,
+			132, 108, 28, 132, 236, 47, 19, 37, 100, 227, 90, 200, 254, 50,
+			233, 63, 13, 51, 49, 46, 134, 237, 253, 114, 38, 78, 187, 165,
+			114, 169, 176, 236, 22, 85, 210, 0, 147, 56, 114, 7, 5, 216,
+			134, 143, 226, 26, 68, 2, 76, 232, 238, 137, 129, 123, 191, 232,
+			222, 34, 52, 64, 152, 245, 1, 130, 199, 156, 179, 60, 18, 90,
+			3, 148, 133, 171, 158, 57, 28, 68, 51, 214, 203, 93, 119, 211,
+			55, 249, 63, 119, 208, 32, 14, 110, 31, 208, 66, 34, 14, 54,
+			111, 31, 32, 77, 189, 26, 68, 2, 236, 27, 214, 32, 16, 113,
+			207, 40, 125, 17, 144, 100, 49, 235, 67, 4, 15, 58, 231, 249,
+			233, 114, 185, 232, 185, 37, 109, 144, 6, 91, 252, 230, 85, 95,
+			200, 251, 82, 85, 251, 252, 41, 113, 41, 247, 123, 176, 187, 200,
+			123, 197, 170, 43, 212, 78, 21, 238, 195, 80, 37, 68, 231, 135,
+			136, 50, 189, 139, 131, 232, 252, 16, 137, 239, 215, 32, 17, 32,
+			63, 68, 31, 21, 100, 88, 13, 204, 254, 117, 130, 127, 155, 16,
+			231, 65, 30, 120, 94, 135, 95, 18, 196, 28, 48, 29, 223, 235,
+			66, 41, 14, 23, 74, 191, 78, 226, 61, 116, 136, 218, 2, 20,
+			115, 246, 195, 196, 58, 178, 123, 198, 16, 33, 31, 227, 234, 242,
+			232, 195, 68, 89, 12, 196, 213, 229, 209, 135, 201, 225, 187, 232,
+			11, 21, 42, 196, 172, 223, 34, 86, 183, 51, 33, 83, 1, 5,
+			74, 174, 124, 165, 1, 101, 87, 165, 241, 136, 14, 149, 198, 136,
+			36, 134, 246, 160, 0, 139, 130, 206, 46, 184, 254, 138, 203, 219,
+			164, 143, 18, 171, 205, 185, 103, 239, 44, 33, 245, 209, 131, 205,
+			25, 177, 66, 5, 128, 174, 165, 21, 110, 119, 227, 66, 206, 124,
+			140, 224, 33, 53, 10, 226, 84, 251, 49, 162, 222, 143, 227, 176,
+			159, 124, 140, 176, 65, 13, 18, 1, 30, 57, 74, 95, 135, 96,
+			144, 16, 179, 127, 159, 224, 207, 16, 226, 108, 243, 180, 156, 37,
+			101, 72, 189, 32, 118, 225, 194, 10, 119, 37, 185, 129, 127, 102,
+			53, 148, 246, 88, 93, 167, 175, 4, 19, 155, 214, 78, 248, 72,
+			143, 246, 122, 255, 136, 195, 51, 225, 239, 147, 120, 39, 117, 129,
+			105, 240, 72, 248, 9, 98, 13, 58, 143, 69, 222, 236, 34, 141,
+			143, 211, 61, 82, 45, 71, 201, 220, 145, 113, 57, 174, 30, 15,
+			63, 65, 172, 253, 65, 1, 22, 5, 252, 16, 93, 87, 68, 32,
+			102, 125, 154, 88, 220, 121, 9, 156, 25, 37, 42, 197, 13, 141,
+			95, 165, 130, 144, 98, 165, 226, 109, 148, 43, 66, 13, 41, 232,
+			112, 212, 26, 246, 185, 75, 213, 156, 218, 233, 237, 172, 219, 71,
+			178, 189, 254, 160, 0, 139, 2, 101, 172, 18, 23, 162, 233, 63,
+			17, 124, 72, 141, 166, 45, 193, 86, 13, 34, 1, 182, 13, 104,
+			144, 8, 240, 32, 135, 172, 70, 113, 28, 103, 214, 211, 4, 31,
+			112, 78, 214, 19, 11, 91, 107, 30, 184, 145, 73, 153, 16, 136,
+			2, 119, 199, 169, 51, 46, 205, 175, 2, 41, 0, 230, 87, 36,
+			174, 69, 50, 152, 95, 145, 129, 253, 144, 84, 35, 46, 78, 255,
+			127, 68, 240, 231, 200, 57, 103, 138, 207, 233, 108, 83, 139, 81,
+			69, 79, 105, 77, 222, 110, 2, 57, 97, 177, 255, 139, 189, 119,
+			15, 147, 235, 40, 239, 132, 187, 170, 206, 244, 244, 212, 232, 50,
+			170, 25, 221, 142, 70, 82, 169, 117, 25, 105, 212, 51, 186, 249,
+			34, 75, 182, 112, 107, 166, 37, 181, 52, 55, 207, 140, 36, 11,
+			3, 51, 61, 221, 103, 52, 141, 123, 186, 103, 251, 244, 72, 30,
+			140, 131, 77, 130, 185, 25, 140, 225, 193, 6, 130, 113, 12, 89,
+			226, 152, 143, 39, 4, 118, 33, 27, 96, 23, 150, 37, 56, 224,
+			13, 251, 236, 199, 194, 38, 78, 8, 155, 60, 64, 194, 61, 4,
+			54, 249, 28, 96, 191, 167, 222, 186, 156, 58, 125, 211, 200, 18,
+			151, 205, 195, 63, 210, 188, 167, 207, 169, 203, 91, 111, 189, 245,
+			86, 213, 251, 254, 94, 231, 115, 129, 50, 140, 181, 8, 178, 93,
+			75, 120, 12, 9, 146, 105, 221, 24, 35, 130, 84, 22, 83, 43,
+			28, 12, 124, 142, 108, 126, 134, 200, 44, 180, 226, 129, 176, 152,
+			158, 17, 22, 211, 16, 5, 251, 238, 139, 36, 242, 99, 130, 220,
+			99, 102, 233, 213, 155, 75, 233, 11, 160, 252, 10, 155, 154, 73,
+			7, 14, 200, 133, 87, 180, 229, 139, 122, 225, 141, 181, 178, 232,
+			127, 35, 248, 255, 85, 11, 111, 12, 22, 94, 245, 224, 6, 234,
+			56, 49, 33, 225, 95, 22, 171, 211, 174, 166, 49, 246, 170, 61,
+			138, 29, 49, 88, 126, 191, 172, 87, 200, 24, 200, 240, 151, 245,
+			242, 27, 131, 229, 247, 203, 98, 125, 122, 4, 65, 29, 136, 57,
+			127, 78, 240, 62, 247, 213, 136, 79, 72, 64, 118, 233, 130, 172,
+			177, 120, 164, 69, 8, 119, 56, 242, 224, 65, 108, 218, 33, 200,
+			90, 167, 188, 79, 137, 13, 173, 78, 80, 147, 224, 126, 94, 188,
+			80, 44, 133, 99, 198, 193, 169, 111, 76, 189, 212, 119, 42, 127,
+			113, 14, 252, 248, 204, 147, 161, 210, 101, 211, 1, 228, 64, 155,
+			12, 217, 34, 72, 53, 158, 49, 152, 2, 127, 78, 88, 175, 38,
+			137, 32, 251, 250, 33, 47, 74, 76, 168, 190, 231, 196, 226, 22,
+			220, 83, 250, 210, 30, 3, 227, 203, 74, 7, 109, 226, 234, 131,
+			203, 118, 89, 160, 208, 151, 207, 5, 236, 19, 12, 122, 142, 180,
+			109, 214, 36, 17, 36, 223, 70, 95, 9, 213, 17, 230, 124, 85,
+			24, 154, 69, 153, 223, 45, 184, 192, 49, 7, 22, 55, 223, 120,
+			224, 230, 61, 71, 196, 24, 249, 249, 156, 87, 86, 171, 122, 53,
+			140, 16, 13, 154, 91, 42, 87, 204, 61, 177, 92, 121, 171, 27,
+			109, 154, 42, 118, 86, 95, 13, 154, 42, 150, 253, 175, 106, 91,
+			40, 6, 203, 254, 87, 133, 45, 116, 6, 154, 234, 48, 231, 107,
+			4, 239, 119, 111, 51, 85, 233, 164, 96, 162, 124, 117, 134, 96,
+			76, 72, 185, 233, 106, 88, 179, 35, 75, 51, 100, 84, 144, 237,
+			155, 52, 137, 4, 217, 189, 87, 147, 68, 144, 42, 63, 88, 140,
+			68, 88, 244, 111, 9, 254, 59, 66, 220, 131, 252, 100, 57, 159,
+			147, 171, 91, 120, 82, 53, 95, 239, 99, 224, 203, 248, 183, 132,
+			110, 160, 105, 26, 21, 164, 152, 42, 95, 39, 142, 208, 94, 225,
+			222, 241, 92, 126, 222, 43, 106, 199, 132, 139, 166, 186, 58, 61,
+			19, 243, 63, 166, 60, 29, 191, 174, 215, 211, 152, 242, 16, 252,
+			58, 105, 223, 24, 60, 32, 226, 65, 247, 102, 122, 66, 213, 142,
+			152, 243, 77, 81, 251, 77, 150, 191, 202, 229, 23, 80, 181, 16,
+			245, 111, 218, 85, 35, 40, 217, 170, 90, 136, 251, 55, 9, 100,
+			198, 19, 131, 218, 194, 156, 191, 39, 120, 143, 98, 180, 88, 219,
+			255, 158, 168, 220, 215, 49, 88, 219, 255, 158, 172, 217, 161, 73,
+			34, 200, 158, 221, 116, 18, 62, 141, 178, 232, 183, 9, 254, 14,
+			25, 114, 7, 77, 171, 205, 150, 2, 214, 73, 209, 80, 41, 12,
+			0, 195, 4, 180, 54, 122, 180, 125, 94, 45, 24, 209, 22, 230,
+			124, 59, 16, 73, 177, 228, 124, 155, 180, 113, 77, 18, 65, 110,
+			223, 33, 21, 30, 28, 103, 127, 155, 236, 252, 14, 57, 35, 251,
+			167, 142, 179, 191, 67, 162, 167, 193, 86, 141, 225, 86, 22, 253,
+			30, 193, 223, 39, 67, 114, 195, 186, 204, 70, 22, 75, 197, 190,
+			43, 53, 84, 172, 82, 223, 11, 26, 42, 86, 169, 239, 145, 182,
+			157, 154, 36, 130, 220, 189, 71, 53, 180, 53, 38, 218, 209, 251,
+			125, 211, 80, 121, 174, 253, 125, 209, 208, 10, 52, 52, 198, 156,
+			31, 18, 188, 205, 157, 5, 24, 15, 97, 220, 151, 75, 151, 125,
+			207, 210, 51, 54, 220, 142, 6, 190, 82, 137, 46, 138, 75, 252,
+			98, 254, 146, 87, 20, 207, 139, 139, 243, 90, 167, 106, 12, 157,
+			108, 185, 236, 93, 2, 173, 114, 175, 124, 225, 62, 221, 9, 177,
+			212, 253, 48, 232, 132, 88, 94, 126, 72, 218, 186, 53, 73, 4,
+			185, 149, 67, 146, 192, 24, 110, 99, 206, 143, 8, 222, 233, 166,
+			120, 210, 106, 150, 10, 48, 17, 173, 214, 65, 152, 241, 17, 239,
+			178, 228, 105, 156, 23, 242, 197, 187, 213, 21, 126, 126, 86, 235,
+			37, 115, 238, 29, 195, 109, 45, 80, 170, 110, 65, 27, 18, 100,
+			219, 86, 77, 18, 65, 198, 119, 208, 59, 40, 118, 218, 88, 244,
+			121, 18, 121, 143, 131, 220, 1, 62, 161, 10, 242, 121, 190, 56,
+			91, 82, 233, 62, 2, 227, 39, 99, 7, 130, 54, 57, 41, 20,
+			213, 61, 47, 214, 211, 23, 83, 199, 105, 19, 74, 224, 167, 98,
+			189, 28, 170, 191, 94, 106, 55, 123, 133, 65, 85, 247, 14, 82,
+			137, 205, 190, 123, 1, 215, 59, 159, 83, 172, 110, 3, 173, 240,
+			83, 221, 209, 54, 208, 9, 63, 213, 171, 106, 27, 104, 132, 159,
+			138, 85, 117, 68, 52, 68, 236, 109, 238, 119, 240, 235, 29, 226,
+			30, 147, 185, 254, 75, 5, 43, 165, 202, 92, 198, 231, 213, 17,
+			230, 141, 78, 87, 160, 120, 216, 222, 220, 239, 180, 50, 8, 207,
+			104, 147, 219, 155, 87, 59, 206, 54, 117, 101, 98, 74, 45, 150,
+			100, 85, 53, 241, 235, 66, 106, 219, 212, 110, 231, 213, 142, 211,
+			29, 60, 192, 226, 193, 86, 14, 187, 157, 54, 185, 219, 249, 45,
+			199, 89, 229, 238, 11, 74, 22, 75, 248, 124, 230, 110, 79, 122,
+			42, 6, 25, 226, 234, 87, 129, 100, 9, 109, 193, 3, 44, 30,
+			172, 88, 9, 62, 102, 109, 114, 183, 243, 160, 227, 48, 229, 84,
+			0, 85, 136, 61, 148, 241, 139, 169, 84, 119, 128, 207, 44, 86,
+			76, 120, 128, 201, 125, 83, 91, 53, 184, 148, 57, 206, 202, 224,
+			1, 84, 213, 177, 134, 14, 169, 170, 9, 115, 94, 231, 56, 93,
+			42, 157, 61, 84, 237, 87, 50, 229, 138, 95, 117, 164, 30, 74,
+			224, 34, 189, 88, 181, 74, 177, 234, 3, 247, 49, 71, 5, 187,
+			180, 41, 215, 156, 215, 57, 172, 19, 180, 115, 155, 104, 206, 27,
+			28, 188, 94, 201, 8, 138, 2, 169, 69, 6, 193, 175, 171, 152,
+			38, 137, 32, 215, 174, 163, 41, 248, 20, 51, 231, 33, 7, 239,
+			113, 111, 230, 231, 231, 242, 217, 57, 62, 179, 152, 47, 84, 250,
+			242, 197, 125, 26, 169, 38, 212, 196, 144, 104, 249, 158, 214, 114,
+			109, 88, 172, 250, 15, 57, 216, 144, 81, 65, 170, 4, 247, 109,
+			192, 175, 135, 156, 117, 59, 52, 73, 4, 217, 179, 155, 62, 128,
+			160, 13, 132, 57, 15, 59, 120, 187, 235, 235, 160, 109, 187, 202,
+			234, 200, 109, 81, 125, 63, 229, 222, 197, 126, 30, 63, 9, 72,
+			95, 113, 49, 138, 241, 113, 21, 163, 210, 119, 46, 239, 93, 238,
+			51, 191, 248, 16, 189, 112, 79, 102, 126, 161, 224, 105, 29, 20,
+			242, 217, 147, 77, 34, 14, 180, 193, 144, 45, 130, 84, 166, 96,
+			27, 240, 255, 97, 71, 57, 63, 183, 129, 193, 243, 176, 179, 45,
+			78, 239, 131, 230, 59, 204, 121, 171, 131, 59, 221, 18, 79, 22,
+			139, 165, 138, 178, 214, 101, 58, 60, 149, 148, 199, 92, 19, 129,
+			111, 207, 66, 40, 12, 167, 159, 202, 99, 9, 49, 25, 75, 122,
+			163, 171, 247, 36, 250, 94, 200, 82, 239, 26, 20, 92, 183, 213,
+			105, 129, 250, 181, 194, 16, 54, 209, 91, 29, 117, 190, 215, 6,
+			54, 209, 91, 157, 53, 140, 94, 0, 133, 129, 88, 244, 237, 14,
+			126, 183, 67, 220, 52, 63, 175, 246, 90, 102, 80, 213, 126, 210,
+			159, 43, 93, 230, 139, 11, 144, 146, 211, 66, 173, 107, 110, 42,
+			181, 193, 190, 249, 237, 78, 235, 102, 122, 27, 204, 1, 216, 55,
+			63, 230, 56, 9, 183, 15, 220, 235, 172, 146, 248, 165, 224, 24,
+			70, 238, 152, 109, 125, 47, 101, 92, 238, 137, 31, 115, 148, 175,
+			113, 155, 218, 19, 63, 230, 244, 238, 165, 199, 84, 5, 136, 57,
+			239, 116, 156, 213, 110, 191, 53, 201, 100, 23, 32, 136, 169, 81,
+			55, 76, 129, 72, 22, 96, 61, 192, 226, 193, 202, 85, 16, 74,
+			218, 38, 93, 107, 127, 91, 40, 169, 158, 176, 6, 185, 114, 209,
+			16, 187, 27, 40, 39, 233, 67, 251, 219, 66, 57, 201, 25, 219,
+			194, 156, 199, 29, 124, 163, 26, 36, 97, 79, 61, 238, 224, 13,
+			154, 68, 130, 220, 184, 95, 147, 68, 144, 135, 110, 48, 217, 152,
+			62, 180, 141, 14, 95, 99, 226, 161, 217, 114, 169, 88, 241, 138,
+			185, 102, 89, 152, 94, 72, 146, 165, 159, 75, 146, 168, 248, 157,
+			116, 235, 73, 128, 151, 81, 43, 107, 170, 120, 41, 95, 46, 21,
+			231, 21, 84, 174, 231, 87, 216, 141, 52, 170, 178, 136, 0, 224,
+			229, 241, 205, 207, 39, 215, 55, 200, 92, 244, 181, 36, 30, 87,
+			47, 199, 127, 70, 40, 111, 92, 180, 191, 80, 42, 250, 30, 235,
+			167, 173, 170, 93, 80, 120, 251, 193, 174, 122, 136, 147, 227, 250,
+			37, 150, 164, 171, 116, 63, 20, 42, 43, 86, 224, 227, 13, 81,
+			89, 199, 87, 46, 132, 160, 62, 15, 210, 152, 70, 46, 3, 156,
+			205, 246, 131, 235, 234, 231, 41, 26, 55, 239, 177, 36, 93, 115,
+			217, 43, 20, 166, 0, 131, 102, 74, 33, 151, 59, 240, 241, 218,
+			186, 73, 118, 198, 87, 139, 247, 207, 136, 215, 229, 117, 41, 187,
+			133, 210, 192, 93, 80, 225, 210, 110, 108, 136, 21, 63, 110, 189,
+			204, 250, 104, 84, 58, 91, 2, 94, 119, 117, 149, 26, 14, 98,
+			92, 189, 196, 146, 22, 78, 180, 250, 174, 21, 190, 219, 80, 23,
+			39, 90, 124, 106, 80, 162, 79, 200, 34, 6, 232, 202, 0, 245,
+			58, 239, 73, 140, 228, 246, 131, 91, 234, 113, 57, 192, 210, 30,
+			95, 97, 3, 103, 199, 207, 210, 93, 161, 241, 87, 248, 178, 115,
+			249, 5, 255, 68, 169, 12, 216, 202, 74, 194, 246, 82, 71, 40,
+			0, 37, 95, 13, 145, 152, 225, 165, 248, 37, 218, 115, 197, 98,
+			149, 116, 157, 9, 128, 124, 231, 131, 183, 84, 22, 148, 186, 34,
+			35, 11, 27, 215, 9, 184, 172, 178, 15, 62, 138, 105, 236, 132,
+			154, 219, 108, 137, 110, 104, 36, 219, 44, 17, 42, 248, 10, 179,
+			203, 237, 91, 230, 219, 178, 75, 241, 8, 123, 51, 170, 154, 178,
+			181, 12, 96, 135, 26, 23, 218, 112, 20, 220, 27, 174, 238, 35,
+			221, 160, 235, 0, 148, 250, 225, 117, 52, 202, 156, 150, 200, 36,
+			162, 223, 68, 0, 147, 218, 242, 175, 30, 38, 85, 161, 156, 182,
+			26, 148, 211, 182, 0, 229, 180, 45, 64, 57, 109, 15, 80, 78,
+			87, 40, 104, 83, 204, 200, 202, 200, 203, 104, 142, 226, 104, 132,
+			57, 44, 178, 3, 29, 188, 147, 247, 246, 246, 14, 142, 242, 145,
+			209, 73, 62, 144, 28, 26, 226, 229, 133, 172, 207, 211, 35, 124,
+			242, 84, 122, 130, 79, 164, 198, 207, 165, 7, 82, 253, 189, 189,
+			189, 234, 48, 93, 112, 41, 159, 5, 102, 8, 227, 207, 130, 87,
+			212, 171, 23, 236, 23, 37, 118, 85, 20, 50, 145, 198, 58, 232,
+			7, 49, 117, 162, 128, 41, 229, 226, 162, 251, 4, 86, 208, 94,
+			71, 184, 170, 249, 236, 68, 138, 242, 113, 112, 88, 245, 165, 155,
+			188, 178, 198, 76, 204, 68, 213, 177, 171, 202, 134, 169, 237, 207,
+			137, 177, 36, 207, 22, 242, 242, 32, 154, 143, 103, 242, 190, 231,
+			31, 161, 156, 243, 244, 200, 185, 228, 80, 122, 112, 42, 57, 126,
+			242, 236, 112, 106, 100, 82, 159, 188, 235, 242, 203, 161, 173, 162,
+			201, 164, 12, 56, 13, 151, 50, 133, 188, 144, 7, 46, 154, 56,
+			117, 98, 244, 236, 200, 160, 249, 28, 22, 42, 83, 138, 114, 251,
+			133, 1, 135, 15, 172, 124, 194, 131, 169, 145, 116, 10, 62, 4,
+			75, 69, 189, 154, 41, 20, 74, 151, 37, 242, 88, 112, 189, 19,
+			194, 55, 141, 74, 24, 45, 55, 186, 69, 83, 152, 17, 151, 191,
+			72, 83, 132, 17, 247, 244, 203, 233, 23, 16, 112, 22, 49, 39,
+			142, 183, 31, 118, 63, 129, 174, 200, 90, 216, 97, 203, 19, 7,
+			157, 123, 51, 124, 167, 37, 230, 106, 21, 27, 107, 24, 96, 247,
+			37, 232, 118, 35, 110, 195, 219, 203, 96, 181, 234, 27, 66, 140,
+			196, 163, 59, 53, 133, 25, 137, 247, 12, 107, 138, 48, 178, 125,
+			229, 13, 112, 166, 16, 97, 206, 158, 72, 63, 114, 83, 92, 41,
+			34, 62, 239, 249, 126, 230, 162, 188, 191, 109, 164, 16, 27, 222,
+			52, 105, 4, 220, 61, 177, 30, 58, 164, 80, 110, 157, 189, 184,
+			175, 79, 237, 227, 235, 157, 190, 87, 230, 60, 223, 83, 194, 201,
+			189, 160, 18, 63, 112, 163, 8, 161, 224, 238, 13, 161, 224, 238,
+			109, 91, 109, 161, 224, 238, 101, 210, 219, 52, 130, 35, 49, 230,
+			236, 237, 234, 75, 200, 236, 255, 56, 2, 201, 201, 19, 78, 74,
+			225, 215, 70, 36, 126, 109, 159, 211, 75, 199, 37, 190, 235, 161,
+			72, 10, 185, 39, 184, 86, 173, 215, 192, 135, 91, 2, 216, 210,
+			67, 177, 221, 16, 231, 1, 80, 141, 55, 226, 117, 238, 205, 230,
+			16, 37, 112, 246, 247, 185, 191, 152, 157, 19, 219, 228, 144, 35,
+			90, 166, 152, 179, 46, 136, 21, 78, 99, 84, 20, 211, 102, 97,
+			56, 222, 72, 215, 88, 24, 142, 55, 118, 173, 165, 175, 208, 24,
+			142, 135, 241, 118, 119, 158, 15, 212, 92, 182, 216, 19, 37, 168,
+			58, 124, 218, 95, 241, 202, 243, 9, 26, 92, 193, 25, 220, 5,
+			251, 12, 83, 180, 176, 88, 42, 242, 80, 90, 97, 81, 119, 84,
+			84, 190, 218, 194, 132, 60, 220, 177, 197, 194, 132, 60, 188, 45,
+			46, 241, 63, 49, 102, 228, 8, 142, 187, 251, 248, 144, 114, 81,
+			210, 70, 95, 181, 31, 77, 237, 228, 22, 95, 59, 226, 115, 27,
+			58, 242, 72, 123, 151, 5, 29, 121, 100, 237, 102, 11, 58, 242,
+			8, 223, 6, 103, 3, 128, 28, 121, 43, 238, 117, 15, 155, 90,
+			133, 185, 200, 107, 33, 14, 175, 80, 61, 113, 68, 57, 134, 138,
+			50, 114, 107, 123, 167, 5, 3, 121, 107, 215, 78, 11, 6, 242,
+			214, 221, 123, 232, 49, 13, 3, 121, 12, 247, 184, 7, 76, 245,
+			89, 43, 54, 101, 25, 245, 58, 80, 128, 157, 17, 255, 88, 251,
+			122, 11, 34, 242, 216, 134, 184, 5, 17, 121, 108, 231, 46, 72,
+			233, 11, 16, 145, 183, 227, 173, 238, 13, 65, 189, 33, 40, 185,
+			229, 84, 221, 226, 136, 50, 108, 52, 201, 219, 77, 151, 91, 16,
+			35, 183, 119, 185, 22, 154, 228, 237, 155, 183, 128, 63, 17, 18,
+			67, 115, 28, 239, 117, 111, 50, 85, 27, 208, 145, 171, 168, 60,
+			234, 136, 82, 12, 37, 202, 108, 215, 224, 149, 81, 196, 200, 241,
+			245, 187, 52, 69, 24, 57, 190, 167, 151, 166, 161, 242, 86, 70,
+			6, 241, 62, 247, 86, 14, 198, 178, 125, 159, 101, 28, 182, 20,
+			78, 72, 248, 76, 166, 78, 19, 90, 29, 81, 150, 161, 162, 140,
+			12, 182, 119, 107, 10, 49, 50, 184, 185, 87, 83, 132, 145, 193,
+			190, 126, 58, 43, 193, 52, 211, 145, 97, 228, 190, 88, 158, 101,
+			214, 81, 180, 218, 180, 189, 146, 225, 215, 248, 142, 95, 195, 92,
+			166, 99, 253, 224, 81, 5, 81, 246, 103, 240, 208, 9, 136, 176,
+			9, 107, 94, 233, 193, 94, 210, 45, 9, 225, 49, 158, 9, 225,
+			49, 158, 9, 225, 49, 158, 233, 144, 7, 71, 24, 180, 235, 25,
+			54, 148, 146, 32, 144, 74, 187, 14, 69, 7, 233, 69, 128, 43,
+			116, 238, 136, 76, 34, 247, 46, 213, 221, 58, 10, 245, 250, 244,
+			87, 76, 178, 59, 98, 251, 0, 7, 156, 8, 13, 59, 129, 15,
+			40, 231, 115, 3, 114, 175, 176, 5, 130, 229, 214, 58, 38, 87,
+			29, 39, 56, 226, 136, 111, 13, 21, 101, 100, 162, 125, 131, 166,
+			16, 35, 19, 27, 19, 154, 34, 140, 76, 236, 219, 31, 36, 144,
+			190, 141, 242, 234, 228, 206, 242, 108, 97, 62, 227, 223, 173, 78,
+			33, 86, 87, 229, 230, 138, 111, 163, 109, 176, 215, 27, 206, 248,
+			119, 179, 46, 218, 178, 144, 169, 204, 201, 157, 81, 219, 184, 36,
+			142, 191, 6, 209, 206, 108, 105, 190, 58, 173, 215, 241, 85, 230,
+			195, 49, 241, 104, 12, 189, 248, 160, 122, 229, 98, 169, 144, 41,
+			94, 148, 94, 195, 38, 207, 244, 210, 130, 231, 239, 3, 205, 38,
+			155, 37, 90, 181, 48, 243, 207, 8, 189, 23, 147, 147, 99, 199,
+			159, 198, 91, 228, 137, 98, 255, 152, 206, 27, 118, 94, 111, 157,
+			39, 197, 183, 167, 159, 60, 74, 91, 89, 203, 150, 200, 143, 16,
+			162, 207, 174, 128, 237, 193, 150, 8, 59, 248, 233, 21, 28, 190,
+			200, 150, 10, 252, 248, 226, 236, 172, 88, 14, 250, 184, 44, 171,
+			199, 231, 185, 12, 56, 56, 85, 188, 178, 202, 69, 47, 61, 100,
+			105, 104, 79, 177, 255, 176, 250, 128, 167, 139, 217, 126, 222, 96,
+			43, 209, 220, 194, 95, 80, 141, 232, 155, 145, 141, 216, 39, 140,
+			46, 47, 151, 247, 43, 229, 252, 204, 162, 116, 201, 45, 66, 200,
+			12, 248, 91, 73, 235, 73, 60, 153, 201, 23, 51, 242, 182, 107,
+			222, 79, 200, 195, 235, 82, 25, 254, 47, 45, 86, 168, 138, 79,
+			144, 247, 199, 9, 8, 215, 134, 195, 213, 74, 197, 203, 5, 118,
+			151, 145, 173, 217, 146, 176, 69, 243, 197, 139, 194, 140, 201, 169,
+			69, 61, 83, 246, 40, 159, 247, 42, 71, 168, 196, 66, 239, 173,
+			106, 24, 172, 197, 246, 230, 8, 188, 66, 202, 94, 37, 163, 246,
+			59, 153, 153, 210, 37, 241, 147, 226, 24, 5, 8, 133, 172, 151,
+			80, 88, 162, 102, 241, 8, 106, 148, 9, 29, 172, 230, 228, 242,
+			126, 182, 144, 201, 207, 195, 76, 170, 223, 136, 124, 209, 230, 133,
+			110, 196, 66, 185, 148, 91, 204, 122, 65, 59, 104, 208, 144, 107,
+			106, 7, 213, 187, 185, 92, 41, 187, 40, 108, 168, 140, 30, 164,
+			125, 165, 50, 7, 244, 112, 62, 159, 169, 120, 229, 124, 166, 224,
+			7, 172, 214, 183, 11, 148, 219, 173, 55, 157, 26, 241, 242, 230,
+			200, 87, 107, 58, 91, 182, 138, 165, 224, 55, 224, 123, 190, 226,
+			139, 30, 21, 101, 81, 37, 229, 133, 54, 163, 238, 68, 1, 129,
+			40, 87, 42, 251, 176, 207, 92, 40, 151, 230, 75, 21, 208, 41,
+			185, 69, 161, 83, 114, 94, 57, 127, 73, 121, 119, 83, 189, 201,
+			85, 177, 174, 74, 130, 130, 93, 23, 56, 118, 240, 203, 101, 33,
+			59, 197, 240, 17, 189, 218, 32, 142, 158, 152, 60, 159, 28, 79,
+			241, 244, 4, 31, 27, 31, 61, 151, 30, 76, 13, 242, 227, 23,
+			248, 228, 169, 20, 31, 24, 29, 187, 48, 158, 62, 121, 106, 146,
+			159, 26, 29, 26, 76, 141, 79, 240, 228, 200, 32, 183, 146, 176,
+			76, 80, 30, 79, 78, 240, 244, 68, 28, 126, 73, 142, 92, 224,
+			169, 59, 199, 198, 83, 19, 19, 124, 116, 156, 167, 135, 199, 134,
+			196, 206, 233, 124, 114, 124, 60, 57, 50, 153, 78, 77, 36, 120,
+			122, 100, 96, 232, 236, 96, 122, 228, 100, 130, 31, 63, 59, 41,
+			182, 37, 148, 15, 165, 135, 211, 147, 169, 65, 62, 57, 154, 128,
+			106, 107, 191, 227, 163, 39, 248, 112, 106, 124, 224, 84, 114, 100,
+			50, 41, 19, 185, 64, 133, 39, 210, 147, 35, 162, 178, 19, 163,
+			227, 148, 39, 185, 204, 177, 121, 118, 40, 57, 206, 199, 206, 142,
+			143, 141, 78, 164, 184, 232, 217, 96, 122, 98, 96, 40, 153, 30,
+			78, 13, 246, 139, 125, 241, 200, 40, 79, 157, 19, 123, 155, 137,
+			83, 98, 187, 28, 234, 40, 229, 144, 124, 70, 180, 222, 238, 38,
+			63, 158, 226, 67, 233, 228, 241, 161, 148, 168, 10, 250, 57, 152,
+			30, 79, 13, 76, 138, 14, 5, 127, 13, 164, 7, 83, 35, 147,
+			201, 161, 4, 229, 144, 100, 38, 57, 148, 224, 169, 59, 33, 75,
+			216, 248, 133, 132, 42, 116, 34, 117, 199, 217, 212, 200, 100, 58,
+			57, 196, 7, 147, 195, 201, 147, 169, 9, 190, 251, 74, 92, 25,
+			27, 31, 29, 56, 43, 179, 163, 10, 86, 76, 156, 61, 62, 49,
+			153, 158, 60, 59, 153, 226, 39, 71, 71, 7, 129, 217, 106, 163,
+			63, 113, 148, 15, 141, 78, 0, 195, 206, 78, 164, 18, 148, 15,
+			38, 39, 147, 80, 245, 216, 248, 232, 137, 244, 228, 196, 81, 241,
+			247, 241, 179, 19, 105, 96, 92, 122, 100, 50, 53, 62, 126, 118,
+			108, 50, 61, 58, 178, 135, 159, 26, 61, 159, 58, 151, 26, 231,
+			3, 201, 179, 19, 169, 65, 224, 240, 232, 136, 232, 173, 144, 149,
+			212, 232, 248, 5, 81, 172, 224, 3, 140, 64, 130, 159, 63, 149,
+			154, 60, 149, 26, 23, 76, 5, 110, 37, 5, 27, 100, 144, 184,
+			253, 218, 232, 56, 159, 28, 29, 159, 164, 86, 63, 249, 72, 234,
+			228, 80, 250, 100, 106, 100, 32, 37, 126, 30, 21, 197, 156, 79,
+			79, 164, 246, 240, 228, 120, 122, 66, 188, 144, 134, 138, 249, 249,
+			228, 5, 62, 122, 22, 122, 45, 6, 10, 246, 194, 240, 183, 37,
+			186, 9, 24, 79, 158, 62, 193, 147, 131, 231, 210, 162, 229, 234,
+			237, 177, 209, 9, 157, 247, 71, 178, 109, 224, 148, 226, 185, 201,
+			52, 195, 35, 27, 212, 25, 76, 60, 114, 20, 206, 96, 118, 202,
+			63, 229, 195, 237, 145, 4, 60, 68, 242, 79, 249, 112, 71, 100,
+			31, 60, 84, 127, 202, 135, 59, 35, 113, 120, 72, 229, 159, 242,
+			225, 174, 200, 54, 120, 184, 67, 254, 41, 31, 246, 68, 210, 250,
+			176, 71, 252, 41, 31, 238, 142, 108, 133, 135, 91, 229, 159, 31,
+			78, 192, 238, 56, 250, 15, 72, 44, 125, 238, 251, 18, 124, 218,
+			172, 188, 211, 160, 32, 61, 31, 44, 246, 12, 32, 104, 8, 85,
+			190, 52, 63, 83, 42, 228, 179, 10, 50, 2, 150, 241, 132, 196,
+			220, 147, 215, 116, 122, 33, 128, 95, 142, 240, 248, 108, 127, 38,
+			94, 253, 100, 166, 63, 23, 167, 148, 159, 242, 202, 30, 159, 158,
+			173, 170, 72, 22, 12, 235, 216, 188, 199, 203, 165, 146, 49, 34,
+			19, 124, 58, 51, 13, 122, 119, 122, 102, 154, 106, 163, 90, 105,
+			218, 192, 240, 82, 135, 105, 211, 179, 211, 50, 77, 199, 116, 110,
+			218, 20, 107, 31, 181, 81, 243, 13, 188, 221, 63, 51, 45, 180,
+			213, 9, 133, 50, 231, 223, 45, 17, 245, 180, 170, 148, 170, 110,
+			73, 112, 98, 113, 70, 49, 195, 54, 235, 141, 115, 43, 85, 1,
+			213, 250, 240, 239, 162, 120, 119, 193, 43, 171, 152, 156, 114, 16,
+			75, 41, 129, 2, 22, 23, 0, 214, 193, 188, 210, 95, 213, 134,
+			130, 95, 50, 94, 148, 114, 43, 115, 122, 98, 116, 132, 123, 197,
+			108, 41, 39, 86, 157, 221, 190, 231, 193, 166, 226, 242, 30, 209,
+			254, 237, 234, 235, 97, 248, 58, 95, 212, 91, 113, 177, 102, 81,
+			202, 207, 75, 159, 113, 207, 156, 55, 138, 229, 65, 24, 159, 182,
+			63, 133, 52, 7, 106, 141, 218, 82, 153, 138, 222, 247, 25, 190,
+			65, 10, 166, 138, 167, 110, 70, 117, 136, 119, 165, 36, 93, 65,
+			68, 209, 114, 153, 47, 105, 212, 70, 159, 103, 124, 26, 92, 24,
+			154, 193, 19, 102, 36, 63, 17, 72, 81, 66, 159, 8, 137, 95,
+			130, 123, 126, 239, 82, 190, 180, 232, 83, 115, 37, 156, 247, 109,
+			104, 254, 58, 45, 206, 248, 106, 125, 246, 181, 92, 206, 242, 123,
+			229, 31, 156, 103, 248, 17, 126, 240, 160, 166, 102, 130, 31, 56,
+			207, 241, 35, 252, 64, 64, 222, 35, 222, 212, 228, 125, 250, 143,
+			37, 241, 210, 33, 106, 63, 124, 197, 17, 126, 88, 44, 121, 114,
+			75, 176, 88, 168, 200, 72, 187, 98, 169, 98, 184, 97, 214, 76,
+			137, 138, 169, 226, 50, 128, 55, 247, 36, 150, 64, 98, 95, 65,
+			249, 110, 153, 179, 74, 94, 176, 106, 88, 19, 133, 98, 99, 185,
+			37, 75, 9, 87, 46, 201, 130, 79, 96, 42, 114, 49, 162, 148,
+			151, 22, 43, 11, 139, 149, 61, 71, 232, 11, 239, 250, 125, 186,
+			119, 98, 221, 11, 32, 54, 229, 20, 13, 159, 80, 122, 247, 64,
+			76, 191, 50, 22, 33, 254, 117, 161, 228, 27, 220, 140, 12, 149,
+			10, 64, 193, 25, 11, 81, 77, 11, 145, 51, 250, 134, 203, 91,
+			67, 93, 172, 210, 8, 112, 75, 27, 158, 67, 9, 57, 121, 131,
+			57, 165, 128, 117, 65, 4, 10, 102, 179, 189, 59, 227, 131, 239,
+			185, 93, 195, 172, 245, 2, 229, 115, 25, 49, 95, 189, 98, 32,
+			143, 48, 131, 70, 132, 25, 36, 55, 204, 6, 115, 210, 191, 155,
+			231, 74, 158, 108, 89, 209, 203, 10, 233, 42, 231, 141, 179, 171,
+			28, 19, 202, 43, 165, 133, 62, 137, 25, 82, 45, 136, 253, 60,
+			93, 228, 89, 141, 71, 10, 24, 39, 117, 59, 101, 213, 167, 123,
+			149, 203, 151, 189, 108, 197, 212, 98, 138, 78, 128, 191, 75, 190,
+			186, 88, 170, 108, 213, 80, 193, 122, 26, 249, 21, 47, 147, 179,
+			249, 5, 49, 159, 249, 98, 46, 127, 41, 159, 91, 204, 20, 116,
+			115, 141, 253, 106, 84, 153, 57, 148, 21, 165, 215, 235, 141, 210,
+			77, 243, 94, 101, 174, 148, 75, 80, 101, 228, 218, 231, 127, 150,
+			237, 217, 47, 244, 80, 217, 11, 154, 166, 155, 164, 36, 157, 242,
+			108, 193, 203, 148, 11, 75, 198, 128, 134, 25, 126, 81, 186, 60,
+			72, 208, 109, 48, 81, 179, 133, 140, 18, 2, 213, 224, 228, 88,
+			186, 159, 243, 116, 145, 74, 55, 164, 140, 239, 73, 6, 120, 128,
+			206, 162, 35, 38, 106, 186, 101, 110, 194, 125, 27, 85, 152, 114,
+			125, 119, 46, 163, 107, 199, 210, 126, 93, 21, 123, 86, 170, 240,
+			81, 205, 115, 159, 218, 120, 165, 82, 129, 85, 171, 121, 3, 243,
+			4, 224, 190, 249, 236, 156, 150, 91, 121, 124, 65, 121, 37, 83,
+			190, 232, 85, 108, 214, 3, 158, 86, 73, 229, 253, 18, 156, 132,
+			34, 115, 210, 129, 66, 40, 222, 80, 211, 141, 22, 150, 123, 83,
+			209, 237, 75, 22, 20, 173, 165, 144, 235, 235, 99, 153, 78, 174,
+			224, 105, 12, 85, 24, 80, 159, 47, 22, 43, 165, 197, 236, 156,
+			168, 22, 230, 175, 105, 93, 222, 231, 11, 25, 95, 175, 44, 37,
+			170, 19, 161, 41, 151, 54, 217, 86, 213, 132, 132, 89, 43, 242,
+			23, 139, 165, 178, 202, 71, 19, 52, 47, 83, 40, 152, 245, 93,
+			234, 206, 75, 246, 34, 3, 203, 133, 86, 32, 181, 90, 41, 232,
+			142, 194, 63, 173, 230, 125, 130, 23, 189, 203, 186, 58, 33, 114,
+			84, 37, 121, 241, 138, 57, 185, 148, 128, 200, 220, 147, 247, 101,
+			202, 157, 170, 26, 36, 143, 228, 248, 152, 254, 247, 7, 122, 131,
+			214, 109, 149, 116, 142, 87, 202, 82, 149, 81, 171, 37, 249, 52,
+			104, 201, 233, 106, 53, 89, 181, 236, 214, 140, 88, 77, 73, 102,
+			132, 165, 4, 74, 78, 208, 58, 172, 168, 8, 139, 192, 240, 195,
+			172, 53, 118, 194, 171, 16, 55, 172, 150, 208, 70, 188, 16, 118,
+			148, 189, 152, 203, 91, 36, 235, 77, 85, 64, 157, 69, 57, 188,
+			22, 133, 87, 225, 122, 139, 112, 246, 8, 191, 235, 192, 75, 173,
+			101, 170, 152, 179, 134, 252, 42, 234, 217, 95, 183, 232, 131, 86,
+			209, 192, 169, 124, 13, 107, 243, 210, 182, 80, 230, 237, 93, 194,
+			190, 141, 39, 132, 153, 155, 141, 191, 84, 127, 85, 169, 50, 8,
+			102, 174, 174, 73, 205, 250, 158, 224, 7, 67, 221, 231, 121, 193,
+			243, 224, 208, 1, 156, 38, 229, 9, 131, 202, 131, 0, 154, 95,
+			204, 168, 178, 120, 6, 59, 123, 125, 5, 98, 235, 59, 26, 8,
+			49, 36, 150, 212, 6, 182, 6, 229, 18, 139, 128, 204, 30, 5,
+			167, 172, 0, 186, 44, 127, 237, 209, 177, 84, 213, 198, 138, 197,
+			185, 69, 191, 66, 101, 154, 64, 107, 21, 40, 230, 234, 216, 56,
+			58, 46, 203, 56, 123, 202, 195, 146, 64, 216, 248, 41, 175, 152,
+			21, 38, 99, 109, 123, 2, 115, 160, 100, 43, 171, 132, 225, 72,
+			70, 215, 18, 0, 30, 232, 217, 19, 232, 93, 213, 44, 171, 52,
+			171, 213, 9, 97, 211, 231, 74, 84, 89, 45, 186, 88, 217, 33,
+			223, 104, 65, 137, 110, 125, 217, 76, 232, 144, 0, 133, 44, 158,
+			146, 150, 94, 201, 175, 122, 118, 14, 173, 107, 232, 88, 69, 134,
+			44, 29, 240, 247, 172, 49, 116, 44, 59, 39, 176, 175, 125, 79,
+			49, 192, 207, 206, 121, 243, 25, 238, 93, 42, 21, 22, 181, 158,
+			200, 203, 53, 124, 222, 203, 20, 149, 158, 51, 27, 32, 79, 221,
+			170, 7, 86, 210, 221, 197, 210, 101, 5, 194, 13, 47, 148, 189,
+			217, 146, 2, 40, 87, 0, 247, 66, 181, 80, 197, 105, 121, 89,
+			160, 117, 143, 28, 60, 41, 5, 249, 178, 30, 33, 88, 112, 42,
+			10, 11, 126, 177, 120, 25, 128, 37, 131, 37, 90, 236, 88, 140,
+			109, 173, 189, 15, 68, 123, 213, 146, 40, 246, 79, 178, 141, 96,
+			39, 2, 244, 149, 218, 212, 209, 16, 243, 18, 234, 12, 76, 40,
+			187, 76, 145, 123, 229, 114, 9, 2, 241, 138, 37, 112, 26, 72,
+			42, 47, 224, 144, 249, 166, 86, 180, 66, 41, 155, 177, 21, 176,
+			17, 33, 88, 225, 131, 37, 209, 175, 179, 38, 6, 38, 87, 248,
+			230, 36, 231, 137, 101, 201, 87, 166, 139, 109, 247, 222, 157, 47,
+			230, 192, 20, 171, 111, 234, 212, 81, 84, 52, 72, 152, 32, 77,
+			136, 185, 146, 88, 126, 237, 221, 27, 24, 56, 219, 249, 128, 138,
+			68, 82, 182, 138, 206, 163, 41, 77, 76, 185, 193, 1, 90, 180,
+			65, 98, 236, 215, 44, 45, 202, 172, 129, 139, 206, 144, 112, 154,
+			185, 175, 70, 121, 44, 57, 57, 112, 202, 88, 167, 165, 89, 62,
+			118, 118, 50, 52, 153, 253, 76, 37, 239, 207, 46, 201, 26, 125,
+			111, 62, 83, 172, 228, 179, 62, 229, 187, 197, 139, 112, 150, 171,
+			113, 62, 77, 176, 199, 236, 98, 161, 160, 90, 228, 171, 125, 49,
+			108, 155, 83, 122, 219, 92, 154, 181, 173, 56, 169, 205, 196, 27,
+			137, 154, 185, 9, 59, 109, 161, 4, 225, 24, 36, 95, 188, 88,
+			240, 212, 234, 172, 178, 50, 200, 125, 13, 156, 132, 251, 222, 66,
+			166, 108, 146, 118, 0, 96, 111, 166, 95, 86, 228, 203, 51, 219,
+			188, 2, 90, 17, 31, 129, 69, 151, 45, 21, 47, 121, 229, 138,
+			52, 217, 246, 1, 150, 134, 176, 17, 202, 125, 217, 204, 188, 87,
+			16, 31, 169, 35, 247, 75, 94, 81, 195, 10, 11, 33, 12, 60,
+			163, 19, 65, 220, 88, 248, 64, 58, 16, 33, 99, 39, 155, 125,
+			176, 254, 109, 172, 92, 2, 87, 35, 179, 244, 156, 245, 33, 254,
+			204, 43, 243, 219, 248, 129, 163, 250, 233, 216, 156, 216, 87, 46,
+			192, 191, 183, 241, 131, 71, 67, 187, 94, 93, 22, 124, 106, 10,
+			82, 60, 10, 93, 247, 219, 69, 170, 223, 51, 185, 92, 217, 243,
+			253, 80, 169, 48, 26, 114, 43, 155, 169, 182, 98, 166, 85, 139,
+			167, 97, 114, 23, 74, 37, 80, 179, 254, 98, 118, 206, 116, 77,
+			188, 106, 154, 161, 143, 157, 192, 245, 220, 110, 76, 188, 250, 13,
+			232, 93, 60, 212, 6, 41, 17, 198, 225, 95, 139, 132, 57, 176,
+			146, 98, 1, 154, 93, 215, 110, 42, 22, 47, 87, 213, 59, 146,
+			153, 247, 18, 213, 213, 132, 183, 19, 66, 101, 142, 22, 61, 45,
+			158, 126, 213, 145, 84, 165, 236, 101, 42, 214, 42, 84, 18, 175,
+			250, 252, 229, 98, 18, 100, 68, 195, 46, 66, 214, 10, 181, 72,
+			155, 105, 172, 118, 151, 213, 146, 81, 45, 12, 19, 32, 79, 195,
+			138, 50, 61, 129, 74, 120, 197, 243, 43, 83, 242, 79, 203, 78,
+			81, 163, 168, 70, 247, 134, 163, 193, 47, 19, 139, 51, 186, 36,
+			127, 113, 102, 74, 215, 113, 27, 191, 229, 104, 157, 227, 133, 201,
+			176, 174, 146, 174, 113, 205, 71, 212, 26, 68, 81, 194, 104, 185,
+			249, 235, 86, 35, 172, 175, 130, 85, 80, 245, 114, 105, 65, 223,
+			160, 236, 142, 7, 93, 142, 7, 0, 90, 25, 223, 219, 99, 165,
+			14, 81, 135, 105, 234, 120, 67, 169, 207, 96, 68, 249, 57, 175,
+			108, 46, 217, 84, 63, 171, 44, 52, 80, 159, 75, 176, 29, 146,
+			219, 103, 165, 57, 231, 64, 227, 4, 199, 23, 1, 12, 176, 57,
+			193, 212, 139, 133, 58, 125, 188, 36, 234, 146, 170, 92, 229, 200,
+			207, 133, 15, 106, 133, 116, 41, 188, 71, 177, 27, 152, 174, 118,
+			196, 154, 14, 150, 59, 209, 36, 80, 82, 176, 218, 206, 103, 22,
+			22, 84, 202, 153, 118, 233, 254, 228, 252, 3, 138, 173, 49, 89,
+			190, 157, 127, 68, 184, 27, 144, 51, 61, 110, 159, 142, 202, 177,
+			208, 172, 81, 94, 75, 14, 188, 110, 200, 22, 65, 170, 112, 13,
+			25, 124, 244, 143, 136, 173, 215, 36, 17, 164, 187, 201, 92, 70,
+			255, 39, 70, 55, 85, 95, 70, 123, 243, 11, 149, 165, 70, 247,
+			208, 173, 180, 37, 37, 126, 63, 126, 95, 253, 203, 102, 10, 191,
+			234, 139, 230, 254, 101, 94, 52, 67, 149, 87, 117, 201, 252, 179,
+			14, 26, 101, 206, 150, 200, 161, 142, 95, 223, 49, 255, 250, 142,
+			249, 215, 119, 204, 191, 190, 99, 254, 245, 29, 243, 175, 239, 152,
+			127, 129, 119, 204, 41, 125, 31, 44, 254, 212, 119, 204, 230, 226,
+			121, 135, 185, 120, 222, 25, 217, 171, 47, 158, 197, 159, 250, 142,
+			217, 92, 60, 239, 50, 23, 207, 61, 193, 197, 115, 143, 185, 120,
+			174, 186, 99, 254, 23, 12, 119, 204, 228, 80, 164, 195, 253, 62,
+			230, 73, 126, 209, 43, 122, 229, 124, 150, 195, 10, 106, 76, 79,
+			88, 2, 150, 74, 139, 96, 249, 149, 189, 62, 177, 208, 8, 243,
+			255, 82, 41, 159, 83, 215, 8, 66, 253, 45, 66, 74, 48, 216,
+			45, 133, 190, 7, 245, 11, 80, 155, 112, 74, 207, 147, 194, 90,
+			2, 160, 46, 235, 174, 80, 161, 85, 230, 193, 84, 182, 246, 218,
+			148, 43, 173, 102, 174, 109, 42, 42, 137, 99, 166, 104, 153, 101,
+			242, 134, 82, 31, 19, 233, 213, 72, 31, 53, 156, 40, 149, 2,
+			155, 179, 188, 144, 229, 199, 51, 229, 221, 85, 182, 70, 63, 152,
+			26, 123, 148, 9, 230, 243, 6, 191, 31, 13, 155, 197, 176, 113,
+			53, 91, 142, 0, 237, 99, 26, 222, 158, 134, 61, 42, 240, 2,
+			94, 84, 215, 104, 211, 247, 222, 55, 221, 31, 248, 170, 31, 138,
+			173, 52, 22, 212, 239, 78, 95, 115, 4, 226, 92, 169, 34, 150,
+			43, 191, 94, 4, 226, 181, 70, 17, 206, 122, 153, 202, 98, 217,
+			11, 71, 17, 186, 87, 116, 64, 116, 155, 89, 133, 215, 18, 22,
+			25, 63, 67, 187, 6, 0, 144, 234, 148, 236, 181, 142, 41, 59,
+			68, 91, 21, 31, 234, 70, 22, 170, 183, 143, 147, 175, 37, 241,
+			184, 126, 51, 126, 130, 174, 57, 233, 85, 170, 74, 58, 64, 29,
+			177, 160, 234, 232, 199, 175, 37, 113, 157, 0, 72, 253, 13, 188,
+			26, 255, 61, 68, 187, 228, 189, 83, 85, 89, 195, 203, 107, 213,
+			21, 42, 209, 101, 176, 219, 105, 187, 60, 70, 1, 86, 155, 88,
+			200, 106, 193, 53, 187, 20, 217, 93, 42, 191, 17, 15, 68, 75,
+			215, 15, 229, 125, 221, 231, 116, 197, 155, 247, 27, 6, 126, 54,
+			111, 149, 122, 153, 109, 162, 109, 11, 153, 139, 222, 148, 159, 127,
+			133, 7, 77, 106, 25, 143, 137, 7, 19, 249, 87, 120, 108, 35,
+			141, 193, 201, 209, 212, 204, 210, 6, 34, 74, 29, 111, 5, 250,
+			248, 18, 219, 76, 41, 124, 87, 41, 221, 237, 21, 55, 56, 240,
+			35, 148, 52, 41, 30, 196, 203, 116, 67, 109, 67, 77, 24, 105,
+			75, 94, 60, 80, 161, 125, 27, 234, 49, 85, 124, 49, 46, 95,
+			99, 187, 232, 234, 162, 119, 79, 101, 202, 170, 15, 67, 125, 43,
+			197, 227, 49, 83, 231, 127, 65, 116, 227, 184, 87, 206, 20, 239,
+			174, 199, 159, 42, 193, 104, 204, 26, 193, 116, 120, 149, 157, 160,
+			43, 213, 216, 77, 201, 6, 99, 78, 118, 183, 29, 223, 246, 124,
+			114, 19, 221, 88, 247, 91, 81, 163, 248, 126, 197, 156, 213, 2,
+			150, 160, 171, 229, 101, 205, 148, 190, 86, 2, 110, 174, 148, 3,
+			188, 74, 254, 54, 166, 126, 138, 127, 16, 209, 117, 201, 92, 238,
+			58, 142, 241, 33, 26, 149, 48, 149, 170, 3, 155, 224, 179, 181,
+			180, 51, 252, 25, 96, 36, 141, 171, 87, 89, 79, 131, 70, 215,
+			180, 247, 181, 192, 246, 249, 210, 37, 239, 151, 220, 228, 248, 131,
+			136, 110, 10, 181, 68, 37, 72, 121, 225, 186, 129, 221, 64, 91,
+			85, 218, 80, 213, 16, 23, 190, 170, 31, 242, 170, 95, 141, 143,
+			208, 110, 233, 168, 174, 10, 171, 14, 161, 237, 15, 133, 208, 54,
+			43, 82, 70, 209, 222, 65, 55, 55, 40, 79, 77, 169, 253, 52,
+			166, 215, 17, 53, 171, 234, 170, 170, 113, 243, 214, 193, 111, 68,
+			105, 76, 151, 198, 78, 211, 149, 33, 181, 204, 182, 133, 99, 157,
+			235, 168, 108, 183, 110, 5, 241, 8, 27, 164, 52, 208, 202, 44,
+			28, 132, 92, 163, 174, 27, 150, 114, 154, 174, 12, 169, 228, 170,
+			22, 213, 83, 215, 13, 203, 74, 211, 149, 131, 94, 193, 11, 202,
+			186, 82, 163, 214, 213, 232, 100, 48, 15, 226, 17, 150, 161, 29,
+			213, 106, 141, 237, 8, 199, 148, 215, 87, 207, 238, 206, 43, 188,
+			101, 34, 134, 207, 81, 86, 171, 196, 216, 174, 208, 231, 13, 181,
+			92, 147, 166, 143, 209, 213, 85, 90, 133, 109, 15, 135, 152, 215,
+			213, 57, 77, 74, 132, 150, 86, 207, 251, 154, 150, 54, 80, 12,
+			77, 202, 125, 49, 237, 170, 55, 139, 217, 238, 198, 37, 135, 39,
+			122, 147, 178, 23, 232, 218, 186, 51, 137, 237, 169, 19, 79, 93,
+			127, 246, 186, 189, 203, 121, 245, 58, 6, 92, 127, 236, 12, 109,
+			101, 45, 45, 145, 183, 225, 95, 71, 92, 215, 70, 92, 191, 44,
+			136, 184, 238, 13, 34, 174, 119, 194, 159, 132, 145, 85, 42, 36,
+			219, 97, 100, 117, 100, 59, 253, 32, 146, 129, 216, 107, 35, 151,
+			144, 155, 226, 122, 216, 130, 24, 107, 121, 214, 43, 195, 118, 229,
+			94, 37, 20, 249, 60, 159, 41, 2, 234, 190, 249, 178, 159, 30,
+			124, 49, 239, 237, 237, 29, 29, 25, 186, 96, 69, 115, 159, 79,
+			79, 158, 226, 211, 58, 46, 248, 222, 228, 208, 216, 169, 228, 43,
+			39, 38, 147, 199, 135, 82, 247, 77, 67, 104, 55, 188, 166, 79,
+			102, 212, 155, 211, 38, 243, 116, 217, 203, 228, 236, 136, 238, 181,
+			177, 14, 250, 160, 137, 232, 222, 130, 95, 228, 254, 115, 16, 118,
+			60, 50, 58, 201, 199, 83, 201, 193, 11, 148, 75, 85, 13, 0,
+			74, 222, 101, 174, 180, 125, 179, 200, 98, 112, 13, 150, 78, 37,
+			234, 109, 157, 39, 155, 231, 74, 18, 255, 233, 30, 40, 130, 115,
+			158, 28, 130, 90, 166, 82, 119, 166, 39, 38, 39, 228, 77, 185,
+			254, 40, 12, 231, 36, 207, 187, 46, 23, 131, 251, 80, 177, 142,
+			137, 50, 50, 5, 232, 154, 44, 213, 111, 24, 194, 156, 225, 211,
+			186, 241, 128, 14, 4, 91, 51, 104, 102, 40, 84, 123, 75, 180,
+			211, 10, 213, 222, 178, 118, 143, 21, 170, 189, 229, 134, 163, 154,
+			101, 136, 145, 30, 124, 75, 3, 150, 233, 64, 109, 107, 23, 235,
+			229, 244, 248, 94, 41, 40, 59, 248, 64, 51, 98, 121, 129, 233,
+			246, 183, 77, 34, 212, 189, 96, 8, 155, 196, 122, 107, 44, 59,
+			59, 216, 91, 21, 89, 27, 229, 221, 19, 237, 176, 162, 188, 123,
+			216, 118, 43, 202, 187, 167, 255, 70, 250, 247, 50, 186, 29, 51,
+			178, 31, 191, 200, 125, 174, 46, 203, 228, 242, 235, 7, 195, 127,
+			37, 54, 93, 95, 230, 168, 235, 241, 229, 176, 199, 92, 210, 107,
+			23, 189, 178, 199, 225, 176, 19, 28, 194, 100, 207, 133, 120, 236,
+			55, 130, 4, 61, 55, 130, 132, 9, 35, 251, 111, 56, 74, 255,
+			189, 228, 10, 97, 228, 48, 62, 237, 254, 94, 93, 174, 72, 35,
+			227, 151, 198, 149, 28, 84, 31, 230, 138, 234, 133, 80, 143, 135,
+			77, 15, 133, 134, 60, 188, 118, 151, 166, 68, 159, 14, 156, 160,
+			31, 146, 83, 5, 98, 102, 207, 185, 239, 197, 205, 166, 74, 198,
+			156, 176, 11, 53, 105, 175, 238, 122, 37, 88, 38, 7, 20, 168,
+			195, 47, 113, 238, 4, 155, 74, 177, 96, 133, 21, 97, 189, 25,
+			37, 202, 10, 79, 42, 7, 49, 114, 60, 186, 78, 83, 152, 145,
+			227, 27, 246, 105, 138, 48, 114, 252, 200, 56, 125, 94, 50, 183,
+			133, 145, 97, 124, 222, 253, 78, 3, 230, 10, 163, 206, 18, 159,
+			30, 159, 195, 182, 115, 185, 98, 164, 129, 57, 125, 233, 181, 37,
+			74, 51, 235, 201, 53, 178, 84, 149, 102, 213, 38, 138, 42, 149,
+			37, 175, 117, 173, 115, 222, 82, 79, 217, 227, 149, 242, 146, 242,
+			172, 149, 159, 53, 229, 255, 116, 213, 38, 115, 218, 66, 167, 16,
+			21, 76, 135, 118, 224, 211, 162, 40, 115, 72, 87, 42, 235, 80,
+			0, 197, 41, 104, 182, 146, 192, 83, 85, 115, 160, 5, 49, 50,
+			28, 221, 160, 41, 204, 200, 176, 123, 72, 83, 132, 145, 225, 99,
+			19, 244, 173, 4, 134, 41, 202, 200, 157, 248, 14, 247, 183, 72,
+			189, 97, 74, 230, 192, 34, 184, 172, 106, 204, 248, 126, 41, 155,
+			15, 32, 214, 165, 4, 5, 227, 176, 108, 101, 80, 53, 21, 130,
+			193, 188, 78, 67, 104, 96, 28, 131, 26, 64, 150, 171, 6, 16,
+			170, 11, 70, 48, 147, 107, 14, 51, 242, 11, 27, 190, 40, 98,
+			228, 206, 232, 90, 77, 97, 70, 238, 92, 223, 167, 41, 194, 200,
+			157, 135, 135, 233, 163, 114, 248, 90, 25, 201, 226, 243, 238, 235,
+			234, 14, 159, 220, 54, 248, 203, 25, 61, 112, 32, 250, 191, 124,
+			252, 202, 208, 221, 95, 141, 33, 108, 69, 140, 100, 205, 12, 108,
+			197, 140, 100, 205, 12, 108, 37, 140, 100, 143, 77, 64, 98, 206,
+			104, 4, 199, 24, 153, 199, 119, 185, 239, 105, 160, 40, 229, 16,
+			106, 59, 53, 227, 251, 249, 139, 69, 29, 184, 244, 75, 89, 123,
+			37, 155, 97, 53, 52, 142, 161, 162, 48, 221, 68, 16, 165, 229,
+			44, 73, 153, 226, 146, 229, 145, 110, 122, 168, 68, 168, 102, 113,
+			149, 204, 139, 33, 70, 230, 163, 174, 166, 48, 35, 243, 221, 55,
+			107, 138, 48, 50, 127, 252, 60, 253, 184, 52, 96, 218, 24, 89,
+			196, 158, 251, 193, 186, 6, 140, 220, 202, 202, 189, 143, 144, 43,
+			0, 80, 208, 39, 70, 58, 142, 167, 62, 46, 194, 117, 68, 46,
+			154, 22, 175, 79, 215, 3, 41, 106, 67, 140, 44, 70, 55, 105,
+			10, 51, 178, 184, 249, 22, 77, 17, 70, 22, 7, 51, 116, 20,
+			174, 200, 90, 238, 141, 220, 143, 144, 123, 188, 46, 74, 81, 232,
+			56, 75, 95, 72, 93, 17, 162, 232, 222, 88, 183, 196, 105, 133,
+			29, 216, 171, 112, 210, 173, 192, 134, 91, 11, 82, 165, 164, 114,
+			109, 244, 211, 154, 189, 11, 56, 48, 204, 120, 114, 210, 244, 91,
+			153, 43, 148, 103, 169, 181, 87, 11, 226, 118, 44, 247, 109, 43,
+			35, 152, 6, 47, 138, 138, 54, 180, 89, 192, 70, 175, 82, 192,
+			62, 114, 243, 243, 170, 174, 181, 154, 138, 49, 242, 170, 117, 183,
+			135, 113, 140, 94, 181, 225, 24, 29, 6, 28, 163, 232, 107, 80,
+			228, 13, 8, 185, 47, 170, 15, 232, 100, 206, 198, 174, 200, 167,
+			118, 9, 97, 228, 188, 6, 197, 54, 66, 166, 73, 132, 35, 44,
+			250, 90, 132, 95, 143, 210, 10, 54, 217, 198, 20, 177, 216, 86,
+			246, 42, 229, 188, 119, 73, 231, 151, 64, 224, 198, 244, 90, 164,
+			144, 92, 37, 0, 234, 107, 145, 130, 126, 6, 220, 34, 231, 181,
+			168, 99, 13, 64, 133, 35, 209, 191, 232, 107, 17, 123, 61, 58,
+			5, 73, 135, 144, 234, 162, 243, 58, 20, 77, 0, 234, 40, 146,
+			112, 35, 206, 235, 81, 244, 36, 29, 3, 120, 149, 232, 195, 40,
+			242, 104, 67, 249, 8, 29, 46, 46, 171, 223, 24, 49, 231, 97,
+			20, 235, 166, 119, 41, 36, 149, 232, 35, 8, 191, 13, 157, 118,
+			207, 216, 34, 210, 227, 243, 105, 193, 130, 233, 32, 146, 69, 123,
+			118, 168, 204, 194, 75, 213, 172, 177, 66, 147, 168, 134, 82, 137,
+			50, 231, 17, 132, 219, 52, 137, 4, 73, 13, 208, 10, 17, 164,
+			202, 15, 10, 184, 43, 209, 71, 208, 186, 183, 161, 52, 48, 7,
+			107, 230, 188, 85, 51, 71, 97, 177, 56, 111, 19, 204, 57, 64,
+			85, 214, 219, 183, 35, 124, 222, 221, 14, 109, 215, 134, 190, 246,
+			79, 175, 219, 38, 20, 133, 111, 54, 105, 18, 138, 80, 201, 56,
+			49, 32, 45, 191, 29, 245, 238, 213, 100, 76, 144, 137, 115, 170,
+			69, 42, 71, 212, 219, 81, 255, 36, 157, 0, 52, 152, 232, 111,
+			163, 200, 31, 162, 70, 24, 99, 213, 71, 167, 205, 7, 232, 70,
+			57, 64, 4, 49, 231, 183, 81, 108, 43, 61, 173, 160, 95, 162,
+			143, 35, 252, 4, 58, 237, 30, 145, 232, 47, 161, 181, 59, 161,
+			156, 6, 75, 151, 225, 84, 0, 50, 125, 22, 10, 50, 152, 87,
+			250, 233, 72, 163, 124, 165, 68, 120, 105, 97, 206, 227, 90, 88,
+			1, 254, 197, 121, 28, 169, 108, 123, 128, 255, 226, 60, 142, 88,
+			167, 202, 157, 46, 198, 227, 113, 212, 245, 132, 26, 15, 162, 199,
+			227, 61, 122, 60, 136, 26, 143, 39, 196, 120, 252, 45, 130, 214,
+			34, 230, 252, 46, 194, 235, 220, 47, 33, 104, 236, 124, 230, 158,
+			252, 252, 226, 60, 47, 46, 106, 244, 118, 185, 242, 202, 153, 180,
+			88, 46, 246, 43, 215, 65, 219, 111, 31, 252, 20, 103, 189, 203,
+			50, 21, 109, 81, 121, 8, 201, 116, 223, 16, 70, 177, 88, 52,
+			203, 78, 130, 103, 42, 124, 190, 228, 87, 248, 129, 253, 251, 247,
+			171, 210, 131, 128, 2, 25, 239, 215, 79, 67, 141, 49, 9, 127,
+			196, 39, 71, 181, 31, 190, 244, 22, 131, 82, 244, 247, 217, 146,
+			87, 206, 74, 161, 23, 207, 13, 27, 81, 11, 244, 178, 85, 147,
+			208, 233, 152, 206, 72, 47, 68, 232, 119, 133, 88, 67, 226, 110,
+			130, 49, 115, 158, 22, 44, 121, 28, 203, 190, 74, 207, 89, 9,
+			141, 53, 159, 177, 124, 199, 229, 76, 147, 46, 86, 122, 178, 41,
+			103, 248, 57, 79, 143, 164, 176, 233, 197, 247, 61, 125, 61, 124,
+			70, 6, 87, 100, 116, 50, 253, 178, 119, 201, 43, 251, 42, 204,
+			0, 114, 133, 192, 231, 58, 101, 64, 143, 95, 201, 148, 253, 68,
+			159, 92, 77, 123, 224, 13, 63, 40, 155, 207, 44, 89, 227, 4,
+			239, 242, 66, 233, 178, 104, 197, 92, 254, 226, 156, 12, 25, 163,
+			106, 45, 150, 153, 242, 43, 37, 14, 193, 45, 53, 131, 18, 30,
+			7, 104, 133, 57, 94, 203, 151, 57, 108, 208, 116, 248, 9, 200,
+			179, 225, 45, 110, 1, 118, 105, 17, 21, 2, 245, 52, 106, 211,
+			188, 133, 148, 105, 130, 183, 255, 159, 20, 55, 194, 156, 15, 33,
+			188, 193, 253, 22, 226, 73, 216, 30, 115, 216, 30, 11, 86, 100,
+			61, 227, 99, 6, 33, 238, 50, 136, 156, 79, 87, 207, 201, 105,
+			200, 96, 218, 79, 249, 152, 10, 226, 145, 121, 132, 2, 85, 47,
+			185, 25, 164, 62, 19, 213, 244, 235, 152, 250, 133, 204, 197, 124,
+			17, 210, 32, 37, 36, 122, 34, 184, 209, 137, 17, 157, 247, 42,
+			194, 62, 9, 156, 29, 75, 245, 234, 150, 65, 17, 243, 153, 74,
+			86, 158, 72, 66, 142, 88, 176, 94, 44, 47, 73, 207, 234, 154,
+			97, 20, 105, 129, 190, 107, 70, 9, 173, 241, 33, 212, 214, 169,
+			73, 224, 204, 186, 245, 52, 77, 177, 227, 176, 232, 71, 81, 228,
+			19, 8, 185, 71, 3, 12, 192, 74, 169, 86, 61, 41, 78, 52,
+			128, 12, 23, 202, 201, 65, 204, 249, 40, 138, 113, 149, 197, 59,
+			194, 156, 63, 66, 120, 155, 194, 218, 150, 163, 110, 236, 199, 192,
+			54, 12, 140, 192, 149, 50, 255, 186, 3, 223, 25, 50, 42, 200,
+			246, 117, 154, 68, 130, 92, 223, 173, 73, 34, 200, 173, 156, 190,
+			5, 233, 108, 237, 31, 71, 120, 179, 251, 128, 24, 115, 53, 220,
+			82, 9, 170, 99, 126, 8, 167, 202, 248, 124, 58, 56, 47, 153,
+			174, 25, 208, 162, 232, 163, 28, 74, 19, 96, 20, 4, 109, 74,
+			79, 213, 132, 140, 96, 82, 38, 109, 181, 12, 248, 161, 116, 239,
+			31, 71, 161, 116, 239, 31, 71, 109, 58, 119, 178, 208, 7, 31,
+			71, 155, 186, 33, 101, 84, 11, 139, 126, 26, 69, 254, 27, 66,
+			58, 249, 131, 90, 53, 244, 124, 215, 71, 33, 193, 134, 164, 89,
+			106, 205, 22, 196, 156, 79, 163, 216, 54, 64, 9, 107, 17, 75,
+			197, 103, 16, 254, 44, 218, 231, 238, 169, 159, 228, 226, 148, 109,
+			200, 200, 227, 14, 104, 164, 204, 123, 251, 25, 20, 202, 123, 251,
+			25, 20, 202, 123, 251, 25, 109, 198, 180, 192, 202, 240, 25, 196,
+			62, 139, 250, 85, 38, 81, 185, 14, 252, 23, 177, 14, 200, 140,
+			181, 106, 169, 248, 172, 88, 42, 126, 138, 84, 178, 220, 232, 231,
+			17, 126, 22, 237, 115, 191, 131, 194, 199, 112, 114, 157, 22, 155,
+			18, 21, 212, 44, 149, 94, 105, 182, 122, 59, 167, 130, 118, 115,
+			98, 102, 205, 231, 139, 42, 64, 89, 191, 44, 177, 54, 213, 94,
+			121, 86, 102, 4, 243, 150, 36, 136, 6, 132, 193, 201, 26, 148,
+			50, 60, 47, 163, 201, 197, 203, 119, 101, 18, 124, 38, 193, 179,
+			9, 158, 75, 112, 239, 165, 9, 241, 162, 80, 177, 119, 229, 18,
+			60, 251, 82, 152, 184, 53, 219, 206, 67, 9, 213, 24, 21, 222,
+			153, 47, 134, 11, 243, 160, 176, 236, 75, 131, 252, 191, 14, 115,
+			62, 143, 176, 157, 14, 248, 243, 200, 100, 110, 21, 34, 243, 121,
+			196, 182, 106, 146, 8, 50, 190, 93, 241, 27, 197, 4, 239, 118,
+			60, 107, 248, 141, 100, 226, 122, 20, 61, 19, 206, 16, 252, 172,
+			224, 247, 195, 72, 167, 8, 254, 34, 194, 167, 220, 251, 17, 159,
+			148, 209, 183, 144, 204, 66, 116, 172, 58, 96, 24, 24, 99, 22,
+			152, 218, 206, 234, 45, 129, 78, 25, 189, 31, 60, 148, 119, 111,
+			15, 54, 166, 208, 245, 62, 190, 93, 175, 36, 30, 68, 155, 136,
+			98, 247, 132, 50, 16, 127, 17, 133, 50, 16, 127, 81, 207, 18,
+			153, 129, 248, 139, 98, 150, 40, 50, 38, 200, 205, 90, 158, 176,
+			236, 223, 23, 209, 214, 20, 24, 94, 81, 22, 253, 31, 40, 242,
+			205, 134, 134, 87, 166, 200, 171, 174, 136, 155, 233, 54, 43, 81,
+			237, 255, 64, 177, 45, 42, 79, 108, 132, 69, 191, 130, 240, 159,
+			163, 211, 238, 161, 43, 206, 166, 76, 46, 103, 157, 179, 1, 168,
+			235, 74, 147, 153, 246, 43, 40, 148, 153, 246, 43, 218, 226, 146,
+			153, 105, 191, 162, 45, 174, 40, 204, 171, 175, 160, 174, 63, 87,
+			22, 87, 84, 79, 163, 255, 169, 45, 174, 168, 154, 105, 127, 46,
+			102, 218, 180, 202, 102, 27, 125, 14, 225, 191, 66, 167, 220, 177,
+			112, 43, 193, 77, 59, 29, 156, 237, 233, 211, 35, 121, 120, 36,
+			26, 27, 98, 79, 38, 167, 23, 41, 185, 32, 79, 7, 169, 110,
+			29, 230, 60, 167, 37, 87, 102, 190, 125, 78, 75, 174, 204, 124,
+			251, 28, 82, 25, 244, 100, 230, 219, 231, 144, 202, 160, 23, 5,
+			201, 125, 14, 109, 254, 43, 116, 82, 245, 72, 9, 234, 95, 6,
+			61, 146, 178, 252, 87, 40, 154, 162, 95, 32, 58, 93, 238, 55,
+			132, 130, 255, 4, 185, 162, 228, 26, 158, 47, 75, 110, 239, 146,
+			130, 187, 221, 178, 67, 225, 118, 120, 177, 44, 122, 92, 80, 23,
+			125, 16, 213, 163, 185, 240, 210, 254, 144, 154, 6, 139, 102, 22,
+			178, 182, 207, 6, 129, 167, 94, 174, 254, 193, 84, 105, 177, 226,
+			231, 115, 30, 53, 151, 215, 229, 76, 241, 162, 204, 201, 209, 136,
+			251, 50, 153, 76, 221, 176, 126, 123, 211, 77, 121, 54, 179, 232,
+			123, 92, 158, 194, 151, 102, 131, 56, 253, 80, 177, 16, 161, 86,
+			167, 109, 82, 227, 234, 68, 216, 114, 5, 44, 214, 83, 115, 250,
+			34, 51, 97, 73, 183, 182, 234, 76, 155, 33, 250, 20, 226, 65,
+			105, 173, 244, 136, 89, 255, 13, 20, 202, 100, 252, 13, 61, 235,
+			101, 38, 227, 111, 8, 113, 57, 43, 51, 25, 127, 7, 69, 126,
+			140, 144, 123, 178, 254, 164, 230, 181, 30, 26, 203, 48, 89, 90,
+			17, 115, 190, 35, 22, 201, 65, 149, 176, 56, 250, 61, 132, 127,
+			128, 78, 187, 55, 44, 99, 145, 132, 163, 178, 192, 174, 9, 165,
+			49, 254, 30, 10, 165, 49, 254, 158, 158, 215, 50, 141, 241, 247,
+			244, 188, 110, 133, 121, 253, 61, 212, 245, 3, 53, 175, 91, 245,
+			188, 254, 190, 158, 5, 173, 106, 94, 255, 64, 204, 235, 41, 149,
+			250, 56, 250, 67, 132, 127, 132, 78, 185, 163, 141, 231, 117, 245,
+			145, 112, 120, 89, 13, 225, 83, 169, 190, 228, 130, 92, 201, 14,
+			115, 126, 136, 176, 157, 58, 249, 135, 200, 36, 198, 20, 211, 250,
+			135, 200, 36, 198, 20, 211, 250, 135, 200, 36, 198, 20, 211, 250,
+			135, 104, 243, 143, 212, 180, 54, 201, 147, 255, 49, 232, 144, 156,
+			214, 63, 18, 211, 250, 188, 76, 149, 249, 60, 138, 252, 38, 70,
+			110, 122, 57, 99, 171, 124, 100, 150, 49, 186, 49, 196, 156, 231,
+			81, 108, 59, 61, 161, 178, 98, 70, 127, 130, 240, 207, 208, 105,
+			247, 166, 229, 142, 174, 125, 238, 25, 202, 147, 249, 19, 20, 202,
+			147, 249, 19, 20, 202, 147, 249, 19, 109, 15, 197, 96, 124, 127,
+			130, 216, 207, 212, 248, 198, 244, 248, 254, 84, 179, 35, 166, 198,
+			247, 103, 98, 124, 95, 170, 82, 107, 70, 239, 199, 248, 213, 248,
+			164, 59, 92, 103, 124, 101, 146, 211, 234, 225, 13, 29, 184, 52,
+			27, 93, 153, 38, 243, 126, 28, 74, 147, 121, 63, 14, 165, 201,
+			188, 31, 179, 141, 86, 154, 204, 251, 113, 247, 102, 213, 29, 49,
+			186, 247, 227, 45, 175, 198, 39, 84, 119, 212, 232, 62, 128, 77,
+			119, 228, 232, 190, 26, 71, 7, 233, 157, 50, 123, 219, 235, 112,
+			228, 33, 140, 220, 211, 13, 70, 183, 174, 231, 81, 179, 225, 61,
+			24, 36, 113, 123, 29, 142, 237, 0, 196, 245, 54, 49, 188, 111,
+			192, 248, 141, 248, 164, 123, 107, 253, 225, 133, 227, 219, 203, 128,
+			60, 102, 14, 131, 47, 123, 252, 114, 70, 2, 0, 204, 122, 149,
+			236, 92, 191, 157, 182, 237, 13, 56, 148, 182, 237, 13, 56, 148,
+			182, 237, 13, 88, 13, 114, 27, 12, 242, 27, 48, 123, 163, 226,
+			74, 155, 30, 228, 55, 224, 174, 35, 50, 99, 144, 26, 228, 55,
+			10, 174, 92, 160, 216, 161, 44, 250, 8, 142, 188, 13, 35, 247,
+			76, 125, 232, 245, 23, 206, 22, 138, 152, 243, 8, 142, 237, 132,
+			204, 68, 84, 108, 195, 222, 138, 177, 76, 164, 72, 97, 119, 245,
+			86, 61, 246, 20, 118, 87, 111, 197, 237, 76, 147, 72, 144, 157,
+			155, 52, 73, 4, 185, 101, 171, 9, 19, 248, 200, 135, 16, 61,
+			115, 141, 190, 252, 42, 209, 124, 157, 40, 129, 43, 251, 243, 95,
+			75, 38, 163, 241, 235, 209, 238, 170, 60, 70, 199, 233, 234, 147,
+			94, 69, 58, 239, 42, 151, 216, 125, 33, 223, 220, 77, 141, 124,
+			125, 141, 115, 118, 252, 94, 186, 246, 184, 216, 226, 235, 130, 140,
+			151, 239, 193, 42, 143, 99, 183, 113, 6, 36, 227, 110, 220, 79,
+			91, 64, 79, 40, 39, 223, 13, 13, 93, 141, 229, 107, 241, 65,
+			186, 174, 186, 114, 229, 137, 219, 107, 28, 151, 165, 31, 46, 11,
+			57, 11, 134, 253, 149, 63, 137, 104, 231, 4, 160, 139, 135, 123,
+			112, 11, 141, 105, 60, 106, 9, 242, 124, 165, 44, 78, 230, 117,
+			214, 69, 91, 32, 241, 174, 242, 144, 151, 68, 216, 201, 159, 84,
+			57, 249, 55, 247, 228, 15, 197, 0, 180, 132, 98, 0, 226, 47,
+			167, 93, 225, 230, 95, 61, 15, 150, 237, 220, 255, 30, 68, 59,
+			101, 250, 222, 121, 72, 246, 16, 68, 142, 132, 71, 187, 169, 228,
+			44, 43, 232, 33, 204, 15, 82, 205, 143, 117, 52, 42, 145, 26,
+			21, 171, 20, 21, 95, 160, 93, 225, 246, 5, 174, 217, 89, 245,
+			172, 174, 107, 182, 250, 96, 220, 188, 181, 108, 150, 140, 211, 142,
+			100, 165, 146, 201, 206, 137, 207, 206, 46, 20, 74, 153, 28, 219,
+			74, 99, 179, 249, 130, 103, 77, 37, 136, 49, 48, 15, 217, 102,
+			218, 10, 56, 149, 197, 10, 20, 186, 66, 197, 212, 168, 103, 241,
+			55, 56, 148, 2, 187, 6, 189, 66, 37, 195, 250, 104, 11, 140,
+			147, 138, 127, 169, 51, 144, 242, 123, 249, 214, 181, 71, 184, 176,
+			155, 40, 205, 102, 253, 41, 185, 244, 66, 198, 175, 38, 41, 166,
+			218, 178, 89, 95, 154, 56, 108, 132, 110, 152, 41, 148, 178, 119,
+			123, 185, 169, 82, 113, 74, 202, 150, 46, 165, 94, 234, 47, 165,
+			123, 102, 199, 215, 170, 207, 70, 139, 90, 136, 161, 188, 51, 116,
+			29, 252, 144, 47, 94, 172, 42, 173, 165, 89, 105, 93, 250, 163,
+			80, 97, 67, 116, 77, 144, 178, 65, 151, 19, 133, 190, 109, 125,
+			62, 217, 77, 221, 112, 223, 66, 169, 197, 58, 130, 47, 85, 105,
+			219, 233, 74, 153, 116, 66, 151, 212, 10, 56, 240, 43, 228, 67,
+			245, 210, 0, 93, 35, 21, 253, 165, 76, 240, 162, 204, 11, 182,
+			190, 54, 33, 217, 185, 76, 97, 209, 27, 95, 61, 171, 254, 86,
+			133, 196, 31, 199, 116, 101, 144, 120, 76, 200, 67, 146, 154, 228,
+			99, 83, 112, 198, 175, 4, 195, 173, 155, 172, 76, 22, 187, 50,
+			99, 147, 215, 65, 70, 142, 211, 14, 89, 164, 87, 94, 174, 164,
+			172, 54, 31, 52, 227, 79, 203, 85, 242, 231, 111, 16, 237, 28,
+			46, 229, 242, 179, 75, 97, 253, 189, 143, 70, 115, 130, 93, 122,
+			194, 175, 175, 21, 22, 96, 231, 184, 122, 141, 29, 166, 237, 197,
+			82, 37, 63, 187, 52, 85, 89, 90, 144, 26, 105, 85, 213, 87,
+			35, 240, 251, 228, 210, 130, 55, 78, 139, 230, 111, 214, 67, 87,
+			43, 189, 49, 165, 167, 181, 212, 88, 171, 212, 227, 1, 249, 148,
+			221, 76, 91, 23, 65, 69, 232, 84, 120, 155, 195, 67, 86, 165,
+			72, 198, 245, 219, 241, 227, 180, 43, 220, 199, 23, 176, 208, 253,
+			7, 68, 185, 85, 72, 72, 62, 236, 117, 59, 196, 181, 250, 50,
+			245, 139, 102, 92, 124, 142, 110, 107, 210, 116, 197, 140, 1, 43,
+			139, 159, 188, 251, 106, 218, 9, 41, 79, 171, 66, 19, 195, 143,
+			143, 209, 141, 98, 5, 169, 207, 157, 23, 178, 206, 197, 51, 212,
+			173, 87, 226, 245, 108, 244, 111, 33, 186, 81, 242, 71, 45, 100,
+			19, 149, 76, 197, 91, 126, 208, 157, 250, 42, 8, 186, 187, 129,
+			182, 248, 162, 8, 53, 152, 91, 106, 197, 74, 141, 140, 172, 72,
+			190, 28, 31, 162, 110, 189, 86, 4, 137, 43, 213, 168, 214, 13,
+			228, 212, 75, 176, 126, 41, 254, 46, 76, 187, 135, 51, 119, 123,
+			80, 221, 137, 114, 105, 126, 82, 229, 51, 210, 253, 186, 153, 198,
+			116, 138, 163, 96, 60, 54, 208, 117, 225, 190, 153, 175, 204, 203,
+			44, 77, 187, 244, 223, 114, 93, 153, 2, 73, 86, 106, 176, 161,
+			162, 96, 250, 35, 107, 109, 158, 164, 27, 76, 81, 102, 28, 213,
+			12, 34, 87, 156, 65, 235, 244, 183, 161, 199, 62, 227, 180, 93,
+			66, 171, 1, 190, 161, 178, 113, 236, 71, 241, 215, 97, 218, 97,
+			152, 115, 109, 105, 71, 217, 110, 109, 95, 224, 70, 246, 133, 54,
+			45, 170, 218, 69, 106, 218, 85, 173, 11, 156, 229, 235, 2, 75,
+			55, 182, 92, 141, 110, 236, 77, 83, 26, 20, 201, 54, 209, 245,
+			35, 163, 147, 233, 19, 23, 166, 38, 47, 140, 165, 166, 206, 142,
+			0, 220, 195, 137, 116, 106, 176, 35, 194, 218, 104, 75, 106, 56,
+			153, 30, 234, 128, 35, 172, 145, 209, 41, 120, 53, 61, 144, 156,
+			76, 143, 142, 116, 224, 131, 143, 182, 210, 168, 212, 176, 236, 24,
+			141, 233, 125, 5, 235, 174, 14, 85, 179, 185, 238, 214, 225, 87,
+			60, 194, 238, 162, 171, 194, 155, 19, 22, 15, 189, 87, 119, 219,
+			228, 110, 111, 250, 142, 9, 79, 59, 75, 87, 216, 54, 63, 227,
+			225, 116, 172, 181, 187, 25, 119, 91, 147, 55, 236, 98, 109, 235,
+			185, 170, 216, 58, 134, 127, 85, 177, 245, 76, 111, 89, 172, 189,
+			120, 85, 21, 91, 103, 237, 174, 42, 182, 222, 202, 23, 143, 176,
+			87, 106, 157, 87, 103, 77, 96, 125, 141, 74, 168, 171, 216, 221,
+			254, 229, 190, 110, 106, 191, 72, 89, 173, 86, 175, 138, 187, 107,
+			184, 144, 184, 61, 87, 124, 207, 174, 168, 86, 169, 86, 85, 212,
+			80, 247, 87, 85, 212, 88, 59, 199, 35, 236, 78, 186, 182, 174,
+			186, 173, 138, 202, 107, 166, 146, 27, 204, 133, 219, 105, 155, 249,
+			138, 109, 174, 95, 90, 211, 18, 174, 67, 252, 222, 7, 31, 66,
+			50, 128, 239, 207, 200, 175, 3, 248, 106, 3, 248, 122, 131, 0,
+			190, 61, 65, 0, 223, 246, 32, 128, 239, 37, 244, 105, 25, 181,
+			215, 210, 21, 121, 13, 66, 110, 82, 31, 213, 95, 117, 208, 158,
+			252, 238, 23, 20, 178, 215, 21, 91, 69, 191, 143, 116, 200, 94,
+			55, 62, 228, 254, 175, 192, 235, 22, 74, 111, 20, 123, 6, 205,
+			92, 70, 86, 85, 229, 77, 40, 243, 194, 206, 103, 84, 226, 130,
+			108, 169, 44, 97, 238, 235, 231, 81, 133, 229, 52, 0, 151, 13,
+			162, 251, 174, 45, 214, 38, 175, 218, 28, 196, 230, 117, 71, 87,
+			89, 177, 121, 221, 29, 91, 173, 216, 188, 238, 222, 125, 244, 159,
+			173, 216, 188, 81, 247, 91, 248, 42, 120, 83, 29, 18, 211, 144,
+			57, 126, 19, 238, 240, 81, 9, 206, 167, 188, 165, 12, 18, 116,
+			224, 235, 151, 47, 154, 82, 12, 191, 2, 183, 34, 131, 165, 28,
+			220, 207, 85, 243, 59, 83, 92, 210, 135, 232, 161, 200, 130, 154,
+			200, 202, 101, 240, 222, 180, 0, 156, 47, 2, 204, 46, 51, 10,
+			165, 162, 167, 251, 176, 27, 114, 163, 148, 189, 61, 245, 106, 175,
+			138, 6, 236, 178, 163, 1, 215, 246, 218, 209, 128, 55, 158, 161,
+			239, 49, 209, 128, 253, 248, 180, 251, 150, 26, 1, 150, 203, 185,
+			231, 3, 8, 182, 238, 158, 152, 34, 74, 161, 4, 183, 185, 250,
+			136, 114, 25, 67, 167, 115, 141, 170, 171, 155, 178, 157, 130, 81,
+			6, 56, 84, 123, 134, 11, 41, 234, 143, 50, 43, 132, 175, 191,
+			171, 199, 10, 225, 235, 63, 120, 130, 190, 26, 235, 16, 190, 155,
+			241, 105, 247, 71, 53, 93, 25, 130, 139, 13, 125, 10, 167, 93,
+			30, 242, 203, 157, 139, 74, 8, 26, 202, 27, 132, 82, 152, 83,
+			199, 105, 126, 43, 223, 95, 43, 48, 166, 148, 235, 58, 63, 173,
+			11, 228, 32, 30, 240, 102, 195, 46, 161, 112, 111, 54, 236, 18,
+			58, 247, 230, 131, 39, 232, 255, 104, 209, 241, 128, 167, 240, 105,
+			247, 115, 45, 213, 236, 26, 150, 185, 119, 124, 115, 111, 90, 204,
+			41, 7, 120, 29, 125, 172, 22, 123, 153, 210, 40, 147, 157, 235,
+			167, 250, 93, 184, 121, 27, 25, 29, 29, 83, 41, 21, 116, 106,
+			88, 94, 181, 37, 15, 82, 190, 4, 78, 199, 50, 153, 130, 253,
+			131, 193, 231, 52, 176, 207, 210, 176, 232, 167, 252, 188, 167, 231,
+			90, 105, 118, 214, 43, 243, 140, 232, 69, 49, 151, 41, 231, 148,
+			151, 55, 52, 136, 207, 120, 242, 234, 223, 187, 36, 4, 76, 106,
+			72, 217, 50, 170, 193, 7, 181, 71, 18, 8, 117, 62, 231, 245,
+			5, 216, 208, 208, 107, 192, 223, 181, 187, 45, 17, 66, 165, 247,
+			173, 250, 152, 154, 175, 45, 100, 105, 223, 43, 230, 212, 199, 98,
+			43, 161, 240, 29, 251, 69, 211, 33, 97, 145, 190, 102, 131, 65,
+			85, 82, 185, 68, 5, 167, 102, 192, 75, 82, 249, 36, 91, 109,
+			54, 222, 95, 153, 156, 199, 1, 141, 21, 214, 72, 158, 225, 194,
+			56, 41, 136, 201, 153, 53, 94, 211, 205, 5, 187, 73, 248, 44,
+			15, 146, 237, 136, 167, 70, 230, 165, 14, 106, 186, 12, 133, 131,
+			92, 174, 111, 152, 212, 124, 230, 110, 79, 130, 50, 2, 120, 152,
+			89, 55, 36, 107, 66, 65, 155, 167, 204, 12, 112, 48, 35, 167,
+			204, 12, 112, 8, 35, 167, 14, 158, 160, 159, 110, 81, 65, 155,
+			206, 157, 248, 66, 194, 253, 112, 227, 41, 16, 182, 160, 151, 49,
+			21, 56, 236, 211, 151, 35, 160, 161, 162, 141, 160, 234, 129, 150,
+			162, 5, 248, 149, 70, 10, 245, 54, 72, 103, 104, 81, 206, 237,
+			32, 233, 129, 80, 206, 233, 100, 36, 97, 113, 164, 70, 30, 109,
+			105, 244, 175, 32, 142, 85, 210, 72, 131, 116, 189, 47, 64, 34,
+			249, 100, 73, 66, 157, 46, 169, 32, 153, 76, 46, 199, 123, 224,
+			207, 158, 32, 250, 123, 74, 226, 203, 11, 91, 236, 226, 156, 250,
+			185, 31, 146, 171, 243, 203, 165, 242, 221, 162, 198, 210, 175, 130,
+			116, 87, 139, 198, 117, 148, 242, 192, 186, 168, 47, 236, 126, 40,
+			246, 245, 206, 232, 86, 43, 246, 245, 206, 109, 73, 43, 246, 245,
+			66, 235, 110, 250, 70, 172, 98, 95, 157, 25, 156, 221, 233, 254,
+			164, 193, 250, 104, 70, 214, 202, 185, 117, 21, 203, 164, 118, 250,
+			186, 226, 114, 89, 223, 120, 85, 178, 252, 115, 176, 97, 237, 146,
+			67, 65, 167, 51, 38, 98, 49, 138, 25, 153, 49, 17, 139, 81,
+			194, 72, 182, 117, 27, 253, 9, 82, 65, 167, 78, 17, 151, 118,
+			186, 223, 173, 15, 203, 161, 166, 1, 28, 86, 42, 63, 5, 181,
+			214, 245, 243, 137, 197, 133, 133, 82, 89, 244, 95, 254, 92, 41,
+			103, 138, 210, 89, 76, 242, 177, 143, 39, 7, 38, 211, 231, 82,
+			188, 239, 24, 31, 76, 13, 165, 38, 83, 131, 213, 143, 39, 198,
+			146, 195, 242, 153, 122, 65, 60, 148, 63, 203, 199, 226, 5, 235,
+			89, 104, 156, 38, 71, 7, 71, 119, 195, 49, 93, 176, 123, 189,
+			249, 240, 77, 55, 239, 57, 194, 7, 21, 32, 174, 196, 178, 246,
+			249, 101, 200, 219, 162, 49, 183, 189, 156, 29, 218, 89, 12, 133,
+			118, 22, 67, 161, 157, 165, 214, 109, 244, 221, 72, 135, 118, 94,
+			194, 195, 238, 155, 235, 242, 73, 236, 201, 125, 35, 79, 42, 208,
+			160, 40, 117, 151, 222, 240, 131, 10, 147, 231, 156, 253, 203, 232,
+			200, 129, 91, 174, 166, 35, 49, 196, 200, 37, 19, 98, 24, 195,
+			140, 92, 50, 33, 134, 49, 194, 200, 165, 193, 52, 125, 210, 81,
+			161, 148, 206, 111, 33, 124, 163, 251, 86, 167, 102, 93, 144, 189,
+			224, 51, 25, 63, 159, 149, 93, 73, 4, 210, 234, 203, 225, 230,
+			11, 115, 25, 223, 243, 19, 102, 70, 249, 144, 254, 68, 83, 212,
+			100, 135, 105, 182, 70, 200, 80, 198, 176, 17, 35, 41, 235, 100,
+			84, 193, 62, 107, 109, 39, 179, 157, 201, 112, 210, 176, 225, 36,
+			191, 132, 67, 139, 101, 76, 101, 161, 126, 228, 190, 66, 218, 233,
+			225, 13, 74, 70, 162, 36, 168, 201, 157, 80, 31, 168, 68, 61,
+			161, 204, 47, 129, 218, 146, 131, 126, 217, 11, 12, 124, 96, 137,
+			220, 17, 44, 148, 124, 147, 224, 67, 22, 115, 41, 95, 42, 192,
+			26, 43, 111, 224, 121, 121, 177, 224, 133, 93, 244, 96, 176, 107,
+			53, 73, 177, 100, 182, 24, 18, 134, 38, 112, 5, 11, 58, 212,
+			88, 159, 128, 63, 84, 33, 147, 189, 219, 231, 254, 226, 236, 108,
+			62, 11, 169, 80, 130, 29, 153, 10, 98, 144, 1, 171, 206, 111,
+			161, 232, 106, 77, 98, 65, 174, 217, 166, 73, 34, 200, 196, 1,
+			21, 179, 26, 125, 3, 138, 60, 12, 199, 27, 141, 178, 190, 171,
+			189, 175, 62, 150, 109, 238, 206, 4, 158, 87, 40, 182, 158, 30,
+			82, 49, 171, 209, 135, 16, 126, 51, 218, 167, 226, 250, 108, 7,
+			47, 201, 247, 80, 130, 247, 0, 79, 254, 33, 237, 177, 39, 241,
+			228, 31, 210, 30, 123, 18, 79, 254, 33, 237, 177, 7, 129, 166,
+			209, 135, 16, 123, 179, 242, 168, 143, 40, 215, 173, 55, 161, 104,
+			10, 188, 187, 116, 240, 169, 243, 102, 20, 77, 128, 115, 42, 98,
+			209, 183, 161, 200, 111, 131, 115, 234, 21, 250, 28, 62, 143, 110,
+			238, 190, 136, 16, 115, 222, 134, 98, 155, 233, 187, 144, 14, 67,
+			125, 20, 225, 199, 208, 41, 247, 245, 200, 78, 246, 46, 121, 0,
+			58, 70, 198, 186, 8, 35, 5, 182, 247, 23, 189, 138, 222, 44,
+			243, 244, 172, 153, 39, 9, 107, 13, 162, 26, 253, 196, 48, 176,
+			42, 149, 152, 62, 57, 128, 253, 131, 62, 53, 208, 1, 49, 161,
+			80, 215, 71, 195, 161, 174, 143, 106, 159, 87, 25, 234, 250, 168,
+			246, 121, 149, 161, 174, 143, 162, 174, 199, 208, 201, 80, 96, 235,
+			99, 200, 73, 209, 52, 116, 21, 177, 232, 59, 17, 126, 23, 58,
+			229, 222, 34, 163, 135, 44, 60, 16, 57, 180, 124, 88, 69, 236,
+			149, 102, 249, 129, 253, 251, 245, 41, 160, 142, 227, 9, 90, 134,
+			28, 230, 188, 83, 251, 178, 34, 240, 118, 124, 167, 246, 101, 69,
+			224, 237, 248, 78, 157, 90, 0, 129, 183, 227, 59, 145, 187, 73,
+			53, 20, 197, 68, 59, 186, 223, 101, 26, 42, 125, 27, 223, 37,
+			68, 225, 156, 140, 192, 125, 2, 69, 222, 139, 144, 123, 170, 113,
+			206, 255, 171, 25, 121, 43, 14, 247, 9, 20, 219, 66, 15, 170,
+			56, 92, 231, 73, 132, 187, 221, 29, 122, 223, 9, 195, 0, 200,
+			8, 22, 106, 146, 45, 242, 24, 28, 253, 158, 212, 221, 150, 209,
+			182, 79, 162, 246, 14, 43, 218, 246, 73, 180, 102, 189, 21, 109,
+			251, 164, 232, 182, 10, 92, 125, 63, 138, 252, 25, 196, 79, 92,
+			65, 154, 237, 75, 144, 6, 61, 186, 41, 8, 92, 125, 63, 138,
+			109, 162, 199, 117, 224, 234, 83, 8, 63, 141, 250, 220, 131, 102,
+			22, 131, 115, 171, 186, 91, 3, 165, 106, 132, 89, 29, 155, 152,
+			99, 31, 21, 146, 234, 48, 231, 41, 221, 67, 25, 191, 250, 148,
+			30, 88, 25, 191, 250, 20, 98, 174, 21, 191, 250, 20, 218, 188,
+			197, 138, 95, 125, 10, 109, 125, 26, 133, 163, 85, 127, 95, 72,
+			96, 40, 160, 245, 105, 228, 244, 210, 111, 69, 117, 252, 234, 39,
+			17, 238, 116, 159, 139, 66, 155, 229, 41, 142, 10, 217, 20, 226,
+			167, 211, 185, 138, 5, 37, 8, 150, 156, 45, 123, 30, 100, 95,
+			21, 235, 189, 78, 231, 169, 122, 228, 221, 179, 80, 246, 180, 174,
+			229, 99, 5, 47, 227, 123, 220, 247, 60, 115, 86, 61, 179, 120,
+			209, 15, 159, 217, 47, 236, 211, 164, 242, 164, 220, 39, 203, 170,
+			228, 23, 164, 17, 59, 95, 18, 139, 100, 206, 171, 100, 242, 5,
+			96, 233, 92, 73, 158, 108, 134, 218, 11, 123, 11, 177, 56, 14,
+			100, 138, 69, 47, 7, 63, 138, 29, 96, 16, 17, 149, 243, 22,
+			202, 30, 224, 106, 139, 177, 184, 116, 72, 252, 59, 155, 185, 84,
+			146, 17, 160, 178, 253, 126, 182, 180, 32, 74, 91, 20, 91, 13,
+			170, 52, 202, 156, 231, 11, 29, 34, 173, 131, 126, 153, 246, 79,
+			166, 217, 18, 230, 74, 201, 132, 91, 22, 150, 100, 100, 91, 54,
+			212, 130, 4, 191, 236, 245, 148, 117, 82, 55, 37, 227, 148, 207,
+			103, 22, 22, 84, 104, 108, 193, 187, 152, 201, 46, 217, 159, 45,
+			241, 244, 32, 40, 135, 97, 101, 45, 153, 246, 45, 21, 43, 153,
+			123, 148, 241, 121, 2, 86, 240, 35, 124, 119, 54, 83, 156, 18,
+			75, 178, 101, 90, 36, 100, 57, 83, 146, 59, 123, 228, 7, 187,
+			15, 36, 120, 79, 178, 80, 80, 130, 215, 147, 224, 61, 61, 250,
+			167, 131, 9, 222, 51, 186, 96, 206, 34, 197, 111, 121, 255, 72,
+			105, 193, 43, 154, 87, 14, 233, 87, 32, 31, 175, 198, 144, 155,
+			247, 172, 119, 229, 198, 240, 200, 188, 103, 62, 186, 193, 254, 168,
+			236, 41, 123, 186, 230, 59, 245, 75, 232, 211, 27, 237, 79, 253,
+			74, 166, 92, 174, 247, 101, 222, 63, 162, 126, 51, 31, 222, 148,
+			224, 61, 35, 6, 125, 69, 188, 172, 236, 192, 162, 119, 217, 188,
+			116, 115, 130, 247, 4, 17, 67, 50, 171, 76, 240, 234, 109, 179,
+			249, 123, 188, 92, 34, 87, 42, 6, 237, 57, 172, 219, 3, 70,
+			137, 218, 37, 212, 54, 72, 253, 48, 179, 36, 251, 98, 133, 81,
+			127, 50, 136, 96, 69, 48, 247, 218, 86, 89, 97, 212, 159, 68,
+			107, 24, 253, 107, 164, 195, 168, 255, 51, 194, 235, 220, 255, 246,
+			203, 136, 44, 191, 250, 192, 242, 218, 184, 242, 6, 97, 229, 161,
+			200, 231, 255, 28, 68, 149, 11, 85, 244, 159, 131, 168, 114, 76,
+			4, 217, 181, 150, 254, 131, 137, 124, 126, 6, 225, 13, 238, 223,
+			44, 63, 242, 217, 214, 228, 191, 200, 168, 231, 112, 189, 129, 141,
+			65, 95, 64, 196, 243, 51, 225, 136, 231, 103, 194, 17, 207, 207,
+			160, 117, 235, 233, 119, 37, 131, 28, 230, 252, 87, 33, 47, 95,
+			69, 255, 215, 133, 221, 39, 236, 232, 250, 132, 202, 37, 58, 179,
+			164, 182, 110, 9, 59, 42, 223, 48, 199, 105, 129, 254, 106, 230,
+			56, 72, 144, 38, 110, 222, 33, 130, 236, 90, 75, 39, 101, 56,
+			248, 127, 71, 145, 255, 137, 144, 123, 226, 138, 22, 205, 50, 86,
+			127, 43, 50, 252, 191, 163, 88, 55, 216, 51, 16, 25, 254, 165,
+			171, 179, 103, 100, 88, 248, 151, 194, 97, 225, 95, 210, 246, 140,
+			12, 11, 255, 146, 182, 103, 100, 88, 248, 151, 132, 61, 19, 132,
+			133, 127, 229, 87, 47, 44, 252, 43, 225, 176, 240, 175, 132, 195,
+			194, 191, 130, 54, 117, 131, 61, 214, 194, 162, 127, 137, 34, 255,
+			176, 28, 123, 204, 246, 30, 105, 14, 36, 210, 130, 152, 243, 151,
+			194, 30, 27, 208, 209, 225, 95, 69, 248, 107, 168, 207, 61, 212,
+			96, 87, 37, 106, 49, 6, 25, 68, 25, 101, 77, 69, 86, 156,
+			248, 87, 195, 113, 226, 95, 213, 123, 0, 25, 39, 254, 85, 189,
+			7, 144, 113, 226, 95, 69, 93, 95, 83, 22, 152, 142, 19, 255,
+			107, 228, 12, 132, 227, 196, 191, 38, 44, 176, 191, 214, 113, 226,
+			206, 215, 255, 21, 235, 121, 25, 250, 253, 117, 173, 231, 101, 232,
+			247, 215, 181, 158, 151, 161, 223, 95, 55, 122, 30, 194, 184, 191,
+			125, 149, 122, 222, 150, 144, 95, 52, 186, 69, 80, 239, 11, 69,
+			182, 144, 129, 226, 223, 14, 7, 138, 127, 91, 235, 121, 25, 40,
+			254, 109, 161, 231, 255, 69, 50, 136, 48, 231, 7, 8, 119, 185,
+			223, 65, 96, 133, 150, 22, 203, 28, 114, 207, 103, 10, 188, 236,
+			73, 107, 91, 205, 98, 56, 120, 129, 148, 161, 202, 112, 213, 15,
+			21, 254, 156, 22, 118, 42, 62, 204, 84, 116, 64, 174, 201, 68,
+			165, 143, 187, 250, 67, 105, 174, 249, 180, 57, 102, 190, 141, 199,
+			245, 205, 177, 237, 91, 163, 125, 22, 103, 253, 125, 7, 226, 211,
+			9, 59, 249, 96, 177, 196, 103, 23, 203, 192, 213, 66, 233, 34,
+			36, 174, 1, 152, 186, 249, 133, 76, 57, 239, 151, 138, 42, 173,
+			170, 198, 85, 243, 245, 233, 171, 102, 149, 88, 18, 127, 16, 176,
+			74, 44, 137, 63, 8, 166, 35, 1, 222, 176, 78, 29, 50, 255,
+			99, 20, 249, 23, 75, 197, 52, 214, 250, 246, 72, 54, 85, 250,
+			81, 196, 156, 31, 11, 165, 127, 147, 10, 153, 119, 254, 9, 97,
+			238, 238, 134, 26, 130, 235, 233, 90, 68, 16, 125, 118, 173, 226,
+			224, 29, 248, 208, 144, 81, 65, 170, 136, 53, 25, 52, 255, 79,
+			72, 69, 172, 201, 160, 249, 127, 66, 91, 182, 74, 197, 31, 197,
+			16, 0, 250, 43, 164, 248, 101, 136, 252, 243, 65, 204, 51, 130,
+			22, 182, 217, 33, 242, 207, 11, 197, 127, 151, 140, 121, 254, 63,
+			40, 242, 106, 140, 220, 97, 158, 44, 242, 140, 113, 223, 148, 183,
+			71, 133, 82, 70, 73, 161, 182, 173, 75, 101, 123, 79, 115, 229,
+			200, 231, 255, 131, 98, 27, 32, 74, 176, 85, 140, 206, 253, 24,
+			39, 173, 8, 230, 251, 113, 40, 130, 249, 126, 220, 182, 198, 138,
+			96, 190, 31, 119, 173, 213, 100, 76, 144, 235, 110, 15, 199, 47,
+			223, 143, 55, 28, 83, 101, 35, 230, 60, 128, 241, 139, 172, 96,
+			226, 7, 176, 82, 113, 50, 152, 248, 1, 28, 91, 109, 5, 19,
+			63, 128, 89, 167, 38, 99, 130, 236, 58, 22, 14, 37, 126, 0,
+			175, 187, 149, 78, 201, 200, 225, 7, 113, 228, 63, 96, 228, 222,
+			193, 79, 149, 10, 57, 223, 190, 167, 43, 21, 61, 125, 102, 173,
+			18, 114, 242, 58, 142, 146, 13, 184, 116, 75, 16, 65, 252, 32,
+			142, 49, 250, 87, 72, 135, 16, 63, 132, 241, 155, 240, 62, 247,
+			89, 20, 28, 76, 45, 23, 16, 205, 156, 79, 206, 120, 212, 96,
+			143, 241, 105, 41, 239, 178, 0, 176, 128, 85, 42, 234, 25, 79,
+			229, 197, 22, 58, 87, 221, 187, 193, 94, 170, 188, 88, 240, 250,
+			102, 50, 162, 138, 65, 175, 156, 191, 164, 114, 43, 75, 111, 21,
+			89, 220, 52, 236, 2, 5, 89, 19, 30, 50, 77, 205, 42, 148,
+			191, 8, 185, 158, 131, 200, 230, 40, 115, 30, 210, 99, 35, 35,
+			155, 31, 194, 49, 59, 178, 249, 33, 28, 138, 108, 126, 8, 179,
+			55, 225, 254, 112, 100, 243, 155, 112, 52, 65, 223, 223, 170, 35,
+			153, 223, 139, 241, 251, 240, 62, 247, 237, 173, 245, 96, 216, 172,
+			246, 86, 35, 178, 241, 113, 157, 106, 94, 189, 11, 57, 68, 131,
+			247, 13, 212, 192, 194, 130, 87, 148, 203, 76, 63, 36, 111, 45,
+			246, 149, 171, 190, 220, 237, 9, 91, 123, 90, 130, 24, 238, 9,
+			166, 190, 66, 123, 87, 3, 160, 48, 14, 33, 173, 123, 49, 71,
+			185, 74, 123, 168, 111, 108, 75, 229, 240, 237, 108, 0, 138, 97,
+			93, 228, 78, 203, 107, 223, 197, 162, 40, 165, 88, 167, 41, 51,
+			153, 236, 221, 53, 105, 204, 165, 153, 144, 8, 55, 68, 121, 108,
+			232, 67, 165, 74, 77, 246, 123, 49, 192, 161, 186, 229, 7, 234,
+			144, 55, 212, 88, 10, 119, 83, 170, 39, 144, 111, 190, 226, 91,
+			146, 33, 246, 94, 9, 62, 87, 186, 44, 54, 43, 50, 35, 167,
+			90, 218, 50, 5, 72, 110, 107, 242, 85, 107, 214, 25, 93, 157,
+			241, 169, 74, 128, 221, 39, 91, 85, 42, 243, 249, 197, 66, 37,
+			223, 103, 220, 202, 100, 87, 161, 128, 65, 15, 48, 54, 39, 236,
+			15, 224, 135, 35, 234, 126, 179, 12, 166, 211, 66, 217, 51, 152,
+			21, 65, 220, 82, 24, 154, 159, 170, 246, 75, 65, 159, 134, 75,
+			168, 233, 4, 207, 7, 41, 227, 199, 83, 99, 67, 201, 129, 212,
+			160, 168, 113, 216, 106, 146, 170, 80, 58, 52, 92, 82, 37, 135,
+			101, 41, 193, 23, 139, 5, 207, 247, 131, 68, 0, 170, 178, 4,
+			13, 170, 154, 134, 111, 167, 67, 62, 3, 85, 89, 1, 120, 186,
+			168, 125, 84, 117, 42, 246, 160, 63, 181, 211, 80, 188, 46, 205,
+			168, 203, 165, 114, 206, 79, 4, 185, 237, 140, 33, 89, 182, 38,
+			45, 223, 125, 94, 115, 196, 187, 39, 235, 45, 104, 31, 154, 32,
+			82, 193, 24, 26, 135, 15, 28, 186, 153, 47, 22, 43, 249, 130,
+			224, 16, 44, 95, 247, 4, 248, 59, 49, 0, 62, 124, 47, 86,
+			192, 135, 18, 3, 224, 189, 88, 1, 31, 74, 12, 128, 247, 226,
+			222, 189, 22, 6, 192, 123, 113, 226, 125, 102, 226, 43, 181, 252,
+			62, 49, 241, 95, 2, 243, 30, 179, 232, 83, 24, 255, 62, 62,
+			225, 174, 225, 3, 217, 30, 223, 134, 240, 61, 120, 139, 86, 102,
+			1, 206, 130, 177, 2, 170, 231, 139, 189, 7, 49, 173, 21, 251,
+			247, 167, 2, 0, 3, 97, 19, 62, 21, 0, 24, 136, 245, 230,
+			41, 204, 186, 53, 73, 4, 185, 149, 171, 198, 227, 152, 104, 218,
+			182, 223, 199, 41, 5, 87, 128, 229, 201, 47, 142, 14, 66, 230,
+			226, 152, 120, 255, 3, 24, 31, 112, 183, 240, 227, 38, 18, 51,
+			116, 1, 33, 187, 161, 138, 39, 14, 188, 110, 200, 168, 32, 219,
+			59, 53, 137, 4, 217, 149, 208, 36, 148, 189, 111, 63, 237, 133,
+			154, 28, 230, 124, 16, 227, 125, 110, 183, 172, 73, 136, 123, 195,
+			122, 28, 249, 178, 33, 163, 130, 52, 245, 136, 13, 246, 7, 113,
+			87, 175, 38, 137, 32, 251, 250, 105, 15, 212, 211, 194, 162, 127,
+			128, 241, 135, 240, 136, 187, 158, 155, 160, 204, 122, 149, 180, 56,
+			204, 249, 131, 160, 146, 150, 22, 65, 26, 198, 138, 61, 227, 31,
+			96, 182, 93, 147, 68, 144, 187, 122, 20, 99, 91, 98, 162, 150,
+			221, 31, 194, 195, 138, 177, 45, 192, 216, 15, 225, 232, 16, 221,
+			6, 205, 136, 50, 231, 195, 24, 239, 112, 59, 249, 16, 132, 120,
+			214, 105, 64, 212, 129, 119, 12, 217, 34, 72, 211, 0, 97, 81,
+			126, 24, 43, 36, 172, 24, 142, 18, 65, 198, 183, 211, 127, 39,
+			23, 231, 86, 230, 124, 20, 227, 132, 251, 111, 145, 53, 203, 236,
+			90, 120, 178, 184, 164, 103, 18, 76, 203, 92, 62, 167, 156, 40,
+			172, 105, 27, 32, 215, 170, 153, 198, 97, 249, 200, 207, 66, 46,
+			74, 169, 34, 116, 18, 238, 115, 90, 201, 201, 165, 93, 30, 218,
+			42, 92, 24, 46, 212, 153, 208, 72, 106, 162, 251, 21, 27, 28,
+			199, 116, 185, 213, 129, 102, 27, 50, 42, 200, 246, 181, 154, 68,
+			130, 92, 167, 103, 98, 43, 17, 100, 239, 94, 250, 28, 146, 208,
+			26, 159, 194, 145, 63, 195, 72, 88, 35, 3, 129, 229, 35, 125,
+			199, 74, 128, 116, 101, 187, 237, 244, 243, 179, 202, 44, 105, 24,
+			158, 1, 104, 220, 226, 133, 122, 33, 10, 176, 190, 142, 78, 166,
+			36, 216, 40, 232, 197, 185, 76, 49, 87, 80, 135, 103, 54, 211,
+			115, 121, 63, 187, 232, 43, 179, 43, 8, 246, 18, 236, 43, 192,
+			61, 131, 87, 110, 116, 239, 123, 83, 128, 238, 241, 41, 28, 91,
+			11, 152, 55, 109, 194, 64, 253, 12, 198, 219, 221, 59, 160, 238,
+			176, 55, 153, 5, 226, 33, 215, 195, 254, 96, 227, 37, 215, 183,
+			186, 214, 21, 64, 19, 7, 128, 31, 81, 168, 97, 181, 5, 248,
+			241, 25, 220, 177, 197, 2, 252, 248, 12, 222, 22, 167, 195, 208,
+			26, 196, 162, 159, 197, 248, 79, 240, 62, 247, 182, 26, 59, 197,
+			4, 34, 132, 23, 231, 250, 246, 138, 44, 93, 168, 223, 207, 106,
+			245, 219, 6, 234, 247, 179, 90, 253, 182, 129, 250, 253, 172, 86,
+			191, 109, 160, 126, 63, 139, 19, 127, 162, 212, 111, 155, 86, 191,
+			127, 34, 212, 239, 32, 52, 15, 179, 232, 51, 24, 127, 30, 59,
+			213, 56, 70, 70, 161, 154, 56, 98, 155, 121, 161, 201, 216, 6,
+			106, 246, 25, 45, 153, 109, 160, 102, 159, 209, 147, 177, 13, 212,
+			236, 51, 152, 197, 53, 73, 4, 185, 115, 151, 106, 164, 80, 179,
+			207, 224, 158, 207, 99, 162, 240, 79, 164, 154, 253, 83, 28, 61,
+			65, 159, 64, 208, 72, 194, 156, 103, 197, 116, 61, 21, 158, 173,
+			48, 47, 75, 202, 39, 182, 80, 146, 219, 239, 170, 225, 84, 220,
+			172, 148, 243, 23, 47, 122, 101, 237, 30, 17, 63, 105, 121, 206,
+			84, 173, 127, 251, 15, 220, 178, 231, 8, 120, 224, 5, 81, 112,
+			106, 15, 5, 78, 171, 122, 226, 202, 124, 42, 178, 75, 66, 181,
+			63, 27, 244, 95, 168, 246, 103, 245, 204, 108, 3, 213, 254, 172,
+			158, 153, 109, 160, 218, 159, 21, 131, 116, 31, 197, 45, 17, 22,
+			253, 18, 142, 60, 135, 145, 91, 2, 113, 213, 185, 99, 109, 95,
+			68, 177, 111, 147, 249, 219, 149, 147, 135, 234, 77, 63, 229, 19,
+			158, 199, 77, 211, 115, 165, 236, 62, 97, 196, 93, 92, 204, 231,
+			188, 125, 222, 124, 38, 95, 232, 159, 207, 53, 187, 62, 110, 129,
+			211, 80, 220, 178, 154, 30, 166, 78, 11, 164, 226, 255, 50, 198,
+			91, 220, 94, 104, 75, 200, 136, 236, 231, 147, 230, 244, 75, 102,
+			245, 23, 27, 23, 201, 129, 22, 233, 40, 241, 101, 140, 55, 106,
+			18, 11, 178, 123, 51, 189, 17, 202, 69, 204, 249, 159, 24, 175,
+			112, 123, 196, 238, 20, 26, 22, 238, 96, 128, 64, 228, 27, 20,
+			215, 22, 112, 215, 23, 223, 181, 106, 18, 11, 146, 182, 211, 35,
+			80, 40, 102, 206, 95, 96, 188, 206, 77, 240, 145, 146, 44, 174,
+			166, 52, 197, 40, 97, 125, 130, 255, 155, 41, 89, 180, 232, 47,
+			48, 94, 163, 73, 40, 171, 107, 45, 28, 113, 80, 22, 253, 26,
+			142, 124, 15, 47, 227, 20, 213, 222, 37, 54, 63, 69, 165, 136,
+			57, 95, 195, 177, 77, 244, 43, 88, 99, 237, 124, 3, 227, 109,
+			238, 51, 56, 216, 28, 218, 91, 82, 161, 152, 251, 121, 210, 28,
+			22, 42, 223, 133, 240, 123, 198, 147, 65, 185, 19, 201, 3, 70,
+			105, 25, 131, 235, 170, 216, 240, 23, 253, 74, 57, 147, 47, 194,
+			94, 234, 198, 253, 66, 176, 193, 209, 46, 83, 224, 61, 249, 249,
+			133, 76, 214, 184, 33, 249, 61, 124, 193, 43, 211, 186, 91, 95,
+			222, 147, 174, 126, 23, 12, 76, 101, 131, 0, 78, 126, 25, 214,
+			171, 124, 241, 226, 62, 61, 63, 40, 159, 14, 32, 42, 132, 29,
+			172, 177, 34, 166, 193, 203, 105, 122, 222, 43, 95, 20, 155, 204,
+			180, 92, 48, 189, 98, 182, 180, 88, 148, 144, 155, 121, 95, 206,
+			213, 4, 244, 32, 159, 243, 202, 220, 207, 95, 44, 130, 200, 0,
+			200, 158, 63, 159, 41, 20, 188, 178, 116, 95, 49, 167, 38, 18,
+			182, 232, 27, 97, 216, 162, 111, 232, 169, 40, 97, 139, 190, 129,
+			215, 117, 91, 176, 69, 223, 16, 22, 223, 105, 24, 20, 196, 156,
+			191, 195, 120, 179, 123, 107, 227, 169, 40, 129, 46, 115, 129, 159,
+			112, 205, 156, 84, 69, 11, 69, 253, 119, 24, 175, 208, 36, 148,
+			189, 114, 131, 38, 137, 32, 55, 117, 211, 34, 84, 140, 153, 243,
+			109, 81, 241, 180, 125, 226, 37, 93, 1, 170, 16, 186, 204, 94,
+			18, 124, 170, 165, 56, 228, 139, 198, 185, 122, 56, 115, 15, 47,
+			120, 197, 139, 21, 241, 27, 191, 241, 192, 193, 253, 251, 133, 180,
+			148, 197, 208, 149, 3, 38, 193, 217, 168, 62, 181, 161, 242, 108,
+			20, 183, 233, 198, 193, 217, 168, 104, 220, 36, 52, 142, 48, 231,
+			187, 24, 239, 86, 215, 60, 246, 217, 146, 104, 154, 94, 172, 116,
+			147, 178, 213, 126, 223, 213, 109, 84, 149, 8, 149, 249, 221, 96,
+			156, 132, 202, 252, 46, 110, 215, 120, 82, 66, 101, 126, 23, 119,
+			111, 215, 36, 180, 97, 87, 15, 92, 60, 181, 179, 232, 63, 226,
+			200, 255, 198, 203, 185, 120, 90, 198, 4, 85, 142, 52, 237, 136,
+			57, 255, 136, 99, 221, 116, 59, 117, 156, 118, 49, 63, 127, 140,
+			113, 183, 187, 22, 170, 80, 103, 13, 97, 191, 146, 118, 144, 181,
+			31, 235, 62, 180, 131, 172, 253, 24, 171, 155, 166, 118, 144, 181,
+			31, 99, 117, 211, 212, 14, 178, 246, 99, 236, 110, 162, 211, 20,
+			59, 43, 88, 244, 121, 28, 121, 53, 65, 238, 248, 85, 232, 152,
+			176, 1, 214, 28, 134, 116, 5, 98, 206, 243, 56, 182, 155, 190,
+			152, 58, 206, 10, 209, 159, 159, 8, 139, 122, 168, 142, 81, 84,
+			163, 118, 170, 28, 166, 164, 231, 105, 29, 109, 3, 61, 91, 1,
+			108, 248, 137, 102, 195, 10, 96, 195, 79, 112, 251, 6, 77, 34,
+			65, 110, 220, 170, 73, 34, 200, 248, 118, 152, 114, 43, 132, 228,
+			253, 236, 250, 76, 185, 21, 48, 229, 126, 166, 167, 220, 10, 152,
+			114, 63, 211, 83, 110, 5, 76, 185, 159, 9, 169, 46, 67, 197,
+			152, 57, 15, 16, 188, 217, 205, 65, 197, 211, 42, 252, 102, 90,
+			91, 61, 58, 124, 65, 6, 53, 228, 2, 113, 86, 43, 177, 48,
+			218, 36, 95, 166, 151, 59, 237, 86, 192, 180, 123, 128, 168, 105,
+			183, 2, 166, 221, 3, 164, 77, 55, 80, 76, 180, 7, 200, 166,
+			110, 58, 71, 177, 179, 146, 69, 31, 36, 145, 215, 19, 228, 190,
+			248, 106, 132, 188, 110, 0, 116, 115, 193, 95, 137, 152, 243, 32,
+			137, 237, 161, 123, 169, 227, 172, 20, 130, 242, 58, 130, 251, 220,
+			205, 33, 193, 175, 150, 60, 104, 242, 74, 24, 249, 215, 17, 108,
+			200, 168, 32, 213, 200, 175, 132, 145, 127, 29, 217, 184, 91, 147,
+			68, 144, 123, 19, 128, 245, 183, 138, 69, 223, 68, 34, 111, 37,
+			200, 61, 221, 124, 2, 212, 137, 215, 110, 222, 159, 85, 136, 57,
+			111, 34, 177, 109, 32, 96, 171, 112, 132, 69, 31, 38, 248, 17,
+			210, 167, 18, 31, 44, 231, 190, 178, 202, 227, 95, 117, 119, 21,
+			28, 118, 63, 172, 199, 111, 21, 244, 239, 97, 162, 238, 73, 86,
+			65, 255, 30, 38, 234, 218, 114, 21, 28, 122, 62, 76, 186, 30,
+			33, 242, 218, 114, 149, 186, 182, 124, 11, 81, 215, 150, 171, 244,
+			41, 232, 35, 196, 233, 165, 47, 166, 216, 89, 205, 162, 143, 146,
+			200, 59, 9, 82, 179, 244, 74, 119, 43, 87, 195, 149, 213, 136,
+			57, 143, 146, 88, 156, 222, 70, 29, 103, 181, 24, 229, 119, 136,
+			81, 222, 39, 117, 122, 117, 132, 67, 243, 155, 150, 213, 48, 238,
+			239, 208, 227, 190, 26, 198, 253, 29, 122, 220, 87, 3, 95, 222,
+			161, 199, 125, 53, 240, 229, 29, 98, 220, 47, 80, 236, 116, 176,
+			232, 227, 36, 242, 187, 4, 169, 68, 33, 87, 84, 124, 118, 252,
+			124, 243, 123, 138, 14, 196, 156, 199, 197, 192, 191, 136, 58, 78,
+			135, 24, 248, 39, 8, 126, 146, 136, 78, 214, 5, 121, 52, 203,
+			172, 9, 190, 241, 101, 37, 208, 238, 14, 24, 237, 39, 244, 104,
+			119, 64, 175, 158, 32, 202, 21, 184, 3, 122, 245, 4, 81, 71,
+			220, 29, 48, 218, 79, 16, 246, 164, 26, 237, 14, 53, 218, 191,
+			67, 148, 155, 96, 135, 30, 237, 39, 197, 104, 115, 104, 33, 98,
+			206, 251, 8, 222, 226, 50, 141, 118, 169, 227, 33, 76, 19, 132,
+			70, 123, 31, 193, 157, 154, 132, 47, 186, 54, 106, 146, 8, 178,
+			123, 51, 8, 207, 26, 22, 125, 138, 68, 62, 176, 44, 225, 89,
+			54, 103, 149, 240, 172, 65, 204, 121, 74, 8, 207, 17, 234, 56,
+			107, 132, 240, 60, 77, 192, 240, 182, 85, 132, 102, 167, 4, 77,
+			151, 28, 21, 27, 38, 187, 71, 107, 64, 84, 158, 38, 42, 151,
+			203, 26, 96, 234, 211, 68, 229, 114, 89, 3, 76, 125, 154, 116,
+			173, 5, 95, 105, 198, 162, 127, 64, 34, 159, 36, 205, 124, 165,
+			27, 28, 62, 52, 179, 196, 25, 98, 206, 31, 144, 216, 14, 122,
+			137, 58, 14, 19, 98, 242, 97, 130, 255, 29, 113, 220, 217, 250,
+			98, 162, 81, 99, 224, 204, 192, 151, 213, 206, 230, 11, 112, 148,
+			1, 86, 141, 181, 73, 242, 101, 168, 166, 52, 131, 131, 48, 7,
+			120, 40, 131, 31, 20, 35, 24, 72, 215, 135, 181, 116, 49, 96,
+			196, 135, 137, 186, 56, 99, 192, 136, 15, 19, 149, 212, 134, 129,
+			116, 125, 152, 172, 251, 119, 68, 238, 145, 153, 146, 174, 143, 144,
+			232, 105, 58, 10, 221, 64, 204, 249, 40, 193, 187, 148, 43, 189,
+			138, 22, 202, 207, 206, 122, 101, 175, 152, 133, 176, 185, 66, 166,
+			146, 191, 228, 105, 164, 229, 105, 221, 173, 126, 117, 73, 162, 250,
+			97, 218, 39, 68, 239, 163, 68, 45, 166, 12, 68, 239, 163, 100,
+			229, 54, 77, 18, 65, 238, 216, 73, 191, 133, 160, 122, 204, 156,
+			63, 38, 248, 38, 247, 185, 208, 233, 146, 190, 201, 128, 205, 185,
+			78, 175, 103, 152, 18, 106, 146, 102, 178, 105, 6, 63, 63, 151,
+			47, 4, 150, 138, 60, 19, 18, 38, 200, 92, 169, 144, 179, 119,
+			49, 249, 162, 12, 184, 0, 223, 76, 216, 56, 200, 176, 58, 184,
+			163, 167, 50, 234, 201, 55, 67, 98, 237, 222, 213, 21, 156, 28,
+			56, 219, 51, 65, 217, 59, 245, 175, 190, 24, 28, 118, 252, 177,
+			86, 126, 12, 227, 168, 32, 149, 242, 99, 176, 168, 255, 49, 217,
+			120, 64, 147, 68, 144, 55, 220, 8, 171, 17, 19, 212, 39, 8,
+			222, 168, 204, 157, 124, 117, 220, 74, 194, 84, 234, 103, 46, 193,
+			157, 9, 12, 214, 108, 190, 28, 120, 209, 152, 118, 144, 22, 40,
+			76, 75, 144, 48, 153, 63, 65, 218, 186, 52, 9, 85, 173, 223,
+			0, 169, 59, 58, 89, 244, 211, 36, 242, 103, 4, 185, 71, 175,
+			60, 149, 154, 31, 182, 117, 34, 230, 124, 154, 196, 54, 208, 20,
+			117, 156, 78, 72, 22, 65, 240, 103, 73, 159, 123, 99, 205, 242,
+			170, 131, 14, 130, 165, 214, 236, 99, 212, 105, 141, 234, 75, 167,
+			76, 28, 161, 251, 210, 41, 143, 212, 244, 202, 218, 41, 143, 212,
+			244, 202, 218, 41, 19, 71, 144, 174, 207, 42, 93, 219, 169, 19,
+			71, 104, 93, 219, 105, 18, 71, 8, 93, 187, 27, 218, 137, 152,
+			243, 57, 130, 153, 235, 90, 140, 151, 247, 136, 202, 194, 51, 77,
+			17, 130, 255, 57, 162, 110, 54, 59, 65, 240, 63, 71, 212, 205,
+			102, 39, 8, 254, 231, 132, 218, 223, 5, 197, 98, 230, 252, 169,
+			24, 207, 13, 245, 199, 211, 20, 42, 44, 191, 63, 13, 250, 39,
+			154, 243, 167, 122, 172, 58, 65, 72, 254, 84, 140, 149, 228, 41,
+			97, 206, 23, 132, 105, 122, 115, 115, 155, 88, 134, 191, 6, 231,
+			29, 97, 115, 184, 19, 182, 84, 95, 208, 51, 184, 19, 228, 227,
+			11, 68, 153, 195, 157, 32, 31, 95, 16, 214, 230, 73, 168, 211,
+			97, 206, 127, 37, 120, 183, 10, 162, 104, 180, 201, 147, 207, 173,
+			13, 104, 81, 59, 3, 155, 90, 29, 89, 146, 33, 163, 130, 84,
+			251, 186, 78, 233, 80, 72, 212, 190, 174, 83, 58, 20, 146, 93,
+			61, 6, 54, 248, 19, 93, 116, 236, 26, 209, 119, 237, 168, 164,
+			58, 208, 193, 87, 4, 6, 62, 127, 221, 234, 175, 130, 0, 62,
+			72, 215, 159, 244, 42, 99, 230, 231, 9, 175, 162, 177, 196, 214,
+			135, 64, 227, 72, 0, 249, 123, 132, 110, 214, 209, 32, 161, 15,
+			13, 72, 222, 70, 13, 227, 43, 81, 115, 37, 52, 169, 68, 236,
+			245, 232, 150, 70, 223, 6, 112, 120, 86, 107, 125, 175, 82, 31,
+			14, 47, 220, 228, 85, 11, 161, 194, 14, 254, 5, 162, 237, 193,
+			27, 62, 59, 71, 59, 170, 107, 100, 59, 170, 49, 189, 234, 113,
+			193, 109, 82, 107, 60, 194, 252, 0, 128, 56, 220, 29, 214, 91,
+			23, 199, 171, 46, 191, 220, 189, 203, 122, 87, 195, 52, 93, 7,
+			40, 164, 191, 89, 69, 163, 204, 113, 34, 183, 34, 250, 180, 4,
+			66, 114, 34, 236, 224, 19, 191, 218, 64, 72, 253, 26, 203, 40,
+			106, 176, 140, 98, 1, 150, 81, 44, 192, 50, 162, 10, 192, 8,
+			49, 210, 30, 201, 208, 63, 148, 168, 69, 206, 154, 200, 14, 97,
+			224, 91, 98, 177, 108, 228, 34, 192, 86, 135, 36, 29, 118, 116,
+			225, 193, 59, 121, 111, 111, 239, 224, 40, 120, 39, 4, 0, 70,
+			233, 17, 62, 121, 42, 61, 193, 39, 82, 227, 231, 210, 3, 169,
+			126, 192, 44, 130, 115, 106, 83, 157, 12, 117, 209, 225, 30, 61,
+			176, 185, 17, 91, 125, 105, 31, 88, 224, 69, 107, 98, 157, 244,
+			3, 88, 131, 23, 109, 196, 103, 220, 199, 3, 128, 30, 85, 243,
+			217, 137, 84, 125, 148, 158, 114, 143, 111, 55, 24, 234, 172, 205,
+			83, 31, 14, 49, 109, 24, 185, 93, 149, 220, 62, 28, 244, 45,
+			76, 152, 125, 178, 116, 133, 125, 115, 21, 128, 57, 10, 43, 4,
+			202, 237, 241, 229, 98, 237, 47, 100, 178, 16, 250, 89, 19, 122,
+			222, 180, 29, 33, 224, 163, 141, 38, 25, 120, 4, 51, 178, 209,
+			36, 3, 143, 16, 70, 54, 30, 57, 73, 159, 49, 192, 71, 219,
+			241, 156, 251, 199, 215, 198, 87, 29, 130, 24, 110, 158, 191, 12,
+			230, 214, 128, 19, 153, 143, 67, 248, 68, 215, 196, 226, 82, 209,
+			216, 63, 1, 167, 85, 49, 33, 126, 135, 216, 109, 125, 213, 172,
+			101, 33, 32, 163, 237, 209, 110, 11, 200, 104, 251, 150, 163, 22,
+			144, 209, 246, 19, 57, 216, 67, 69, 152, 179, 39, 146, 104, 148,
+			46, 67, 84, 87, 173, 253, 184, 55, 191, 188, 236, 176, 123, 98,
+			91, 33, 4, 19, 166, 203, 94, 124, 155, 178, 105, 203, 245, 246,
+			80, 230, 161, 61, 150, 225, 60, 168, 43, 116, 248, 45, 217, 139,
+			99, 86, 146, 215, 189, 109, 43, 173, 36, 175, 123, 59, 214, 88,
+			73, 94, 247, 178, 91, 195, 73, 94, 247, 118, 29, 1, 127, 72,
+			196, 156, 3, 145, 27, 16, 36, 61, 169, 223, 237, 250, 154, 127,
+			89, 169, 113, 5, 239, 15, 196, 118, 130, 69, 143, 68, 231, 15,
+			225, 147, 238, 109, 181, 157, 247, 175, 178, 247, 8, 71, 28, 81,
+			152, 161, 90, 24, 57, 212, 190, 70, 83, 136, 145, 67, 108, 189,
+			166, 8, 35, 135, 220, 77, 154, 138, 49, 114, 168, 251, 4, 240,
+			66, 103, 131, 37, 135, 182, 12, 210, 151, 64, 228, 169, 115, 75,
+			228, 86, 36, 19, 59, 213, 30, 9, 92, 35, 51, 196, 156, 190,
+			37, 182, 11, 110, 147, 177, 96, 198, 81, 220, 167, 12, 87, 75,
+			251, 39, 64, 192, 205, 25, 106, 125, 73, 87, 108, 128, 128, 84,
+			114, 20, 27, 42, 202, 200, 209, 246, 13, 154, 66, 140, 28, 221,
+			184, 91, 83, 132, 145, 163, 123, 19, 198, 124, 252, 95, 131, 116,
+			248, 90, 205, 55, 13, 218, 85, 199, 118, 220, 84, 157, 118, 66,
+			102, 55, 190, 246, 140, 19, 147, 215, 167, 209, 85, 6, 231, 131,
+			136, 174, 149, 112, 9, 218, 165, 174, 33, 118, 237, 215, 146, 120,
+			25, 57, 35, 110, 162, 49, 232, 89, 206, 155, 85, 240, 181, 107,
+			107, 145, 195, 7, 189, 89, 131, 192, 47, 223, 141, 143, 209, 117,
+			39, 189, 74, 8, 228, 93, 53, 228, 166, 144, 225, 27, 191, 2,
+			52, 124, 96, 23, 63, 134, 232, 70, 217, 181, 122, 165, 190, 192,
+			238, 13, 208, 149, 6, 122, 126, 42, 232, 227, 198, 106, 228, 100,
+			83, 159, 236, 231, 138, 172, 245, 40, 62, 65, 55, 14, 122, 5,
+			175, 126, 195, 170, 186, 11, 205, 106, 14, 134, 47, 187, 251, 102,
+			36, 129, 178, 67, 168, 32, 254, 53, 34, 17, 95, 67, 74, 136,
+			248, 111, 72, 152, 237, 234, 246, 168, 125, 197, 97, 218, 166, 15,
+			116, 234, 239, 40, 66, 223, 141, 7, 47, 47, 59, 17, 196, 155,
+			16, 221, 160, 194, 11, 12, 175, 126, 153, 252, 120, 141, 26, 160,
+			170, 246, 40, 126, 220, 78, 87, 133, 4, 75, 51, 165, 177, 100,
+			141, 175, 180, 133, 106, 249, 124, 185, 67, 166, 12, 209, 177, 241,
+			154, 35, 161, 174, 161, 166, 93, 195, 213, 93, 83, 89, 62, 130,
+			34, 131, 44, 31, 161, 148, 45, 213, 16, 227, 122, 118, 5, 153,
+			90, 150, 217, 137, 131, 175, 109, 161, 49, 93, 29, 27, 166, 171,
+			194, 58, 172, 10, 218, 185, 174, 130, 115, 235, 235, 165, 120, 132,
+			77, 64, 30, 30, 155, 213, 108, 123, 245, 230, 180, 206, 204, 117,
+			27, 15, 85, 60, 194, 46, 80, 86, 171, 140, 170, 80, 131, 27,
+			106, 171, 230, 69, 159, 163, 172, 86, 157, 84, 21, 221, 80, 223,
+			184, 235, 106, 146, 76, 164, 196, 138, 21, 32, 42, 135, 39, 112,
+			29, 68, 229, 186, 26, 167, 14, 162, 114, 125, 77, 16, 143, 176,
+			28, 93, 83, 51, 49, 216, 206, 122, 72, 214, 53, 19, 217, 173,
+			109, 78, 221, 249, 21, 128, 105, 27, 169, 169, 5, 211, 174, 154,
+			18, 117, 192, 180, 171, 37, 252, 186, 108, 255, 63, 119, 72, 2,
+			33, 127, 26, 253, 26, 8, 185, 22, 8, 121, 231, 149, 128, 144,
+			95, 70, 63, 168, 142, 20, 186, 34, 35, 200, 77, 5, 168, 31,
+			87, 141, 132, 60, 102, 208, 88, 127, 65, 88, 200, 29, 244, 89,
+			115, 156, 176, 5, 31, 119, 255, 35, 174, 139, 147, 22, 66, 77,
+			212, 154, 138, 239, 206, 46, 250, 149, 210, 188, 188, 163, 216, 83,
+			133, 77, 21, 218, 58, 250, 165, 121, 109, 74, 47, 66, 190, 191,
+			26, 168, 221, 228, 16, 212, 53, 149, 186, 51, 61, 49, 57, 33,
+			17, 128, 229, 221, 71, 40, 78, 66, 237, 216, 52, 252, 132, 117,
+			130, 47, 10, 169, 138, 87, 176, 33, 178, 210, 35, 99, 103, 39,
+			131, 176, 140, 203, 25, 31, 2, 73, 75, 51, 5, 111, 62, 168,
+			34, 95, 92, 88, 188, 2, 100, 221, 162, 76, 91, 87, 132, 230,
+			231, 242, 21, 187, 13, 161, 243, 134, 45, 6, 195, 55, 130, 25,
+			217, 178, 110, 175, 117, 222, 176, 229, 166, 99, 244, 63, 33, 125,
+			222, 176, 11, 159, 114, 255, 176, 6, 216, 239, 164, 216, 222, 100,
+			184, 173, 77, 20, 11, 229, 126, 77, 93, 155, 53, 128, 4, 179,
+			251, 107, 174, 82, 242, 126, 0, 209, 219, 4, 199, 79, 223, 137,
+			168, 157, 151, 89, 229, 53, 72, 159, 66, 73, 180, 182, 249, 187,
+			162, 107, 173, 109, 254, 174, 245, 125, 214, 54, 127, 215, 225, 65,
+			250, 113, 172, 241, 138, 247, 227, 17, 247, 131, 53, 160, 210, 3,
+			213, 176, 156, 166, 203, 215, 208, 189, 90, 145, 10, 247, 166, 142,
+			180, 52, 31, 245, 90, 140, 66, 121, 47, 195, 51, 251, 64, 205,
+			153, 162, 151, 203, 221, 140, 213, 154, 108, 22, 158, 228, 230, 193,
+			253, 190, 14, 159, 133, 164, 236, 55, 96, 126, 192, 75, 3, 230,
+			135, 9, 35, 251, 143, 157, 214, 192, 230, 132, 145, 91, 240, 249,
+			58, 192, 230, 114, 21, 174, 22, 171, 107, 224, 241, 213, 50, 44,
+			7, 13, 88, 38, 195, 2, 230, 148, 202, 134, 109, 117, 121, 35,
+			212, 243, 45, 134, 55, 66, 67, 223, 98, 120, 35, 148, 244, 45,
+			199, 38, 232, 179, 72, 35, 39, 39, 241, 75, 220, 255, 88, 23,
+			232, 80, 31, 239, 193, 241, 157, 54, 20, 64, 69, 215, 32, 102,
+			55, 83, 119, 97, 252, 188, 170, 113, 15, 119, 160, 1, 138, 95,
+			112, 244, 96, 67, 114, 138, 239, 194, 8, 219, 14, 98, 36, 105,
+			58, 238, 96, 70, 146, 166, 227, 14, 97, 36, 121, 236, 78, 250,
+			39, 72, 1, 230, 146, 52, 190, 224, 254, 81, 67, 180, 123, 209,
+			105, 169, 114, 133, 249, 127, 77, 189, 230, 63, 199, 46, 183, 32,
+			70, 210, 209, 245, 22, 106, 106, 122, 227, 1, 11, 53, 53, 125,
+			235, 89, 122, 90, 129, 166, 146, 97, 124, 218, 189, 237, 74, 35,
+			173, 141, 127, 62, 87, 130, 1, 43, 21, 205, 89, 124, 8, 115,
+			116, 216, 32, 19, 71, 49, 35, 195, 6, 153, 56, 74, 24, 25,
+			62, 120, 66, 1, 6, 58, 227, 145, 23, 35, 119, 160, 238, 169,
+			94, 120, 35, 208, 252, 240, 234, 80, 112, 140, 57, 30, 219, 12,
+			73, 127, 193, 249, 125, 18, 159, 59, 229, 30, 14, 33, 230, 153,
+			195, 187, 203, 176, 184, 89, 17, 221, 6, 160, 193, 92, 32, 7,
+			103, 152, 147, 161, 51, 204, 201, 182, 213, 214, 25, 230, 164, 138,
+			89, 134, 51, 76, 103, 178, 235, 220, 201, 240, 33, 230, 89, 167,
+			151, 174, 10, 224, 4, 201, 57, 231, 4, 45, 64, 11, 17, 35,
+			23, 240, 128, 59, 5, 13, 148, 141, 48, 138, 178, 159, 242, 116,
+			69, 134, 173, 40, 52, 202, 92, 222, 95, 40, 100, 150, 166, 96,
+			89, 7, 183, 20, 121, 151, 12, 43, 114, 30, 128, 223, 203, 210,
+			75, 8, 92, 35, 124, 175, 82, 201, 23, 47, 250, 166, 31, 40,
+			42, 170, 51, 148, 168, 188, 189, 83, 83, 132, 145, 11, 235, 214,
+			107, 42, 198, 200, 133, 13, 199, 85, 55, 100, 144, 9, 185, 224,
+			222, 78, 199, 228, 89, 236, 203, 34, 51, 200, 29, 172, 59, 106,
+			85, 219, 173, 101, 31, 192, 190, 44, 182, 5, 98, 198, 145, 24,
+			182, 41, 156, 233, 179, 14, 77, 167, 20, 243, 229, 161, 233, 148,
+			58, 64, 150, 135, 166, 83, 29, 26, 207, 79, 48, 127, 138, 101,
+			18, 192, 107, 5, 44, 72, 166, 157, 161, 240, 41, 106, 198, 233,
+			133, 160, 0, 204, 156, 217, 200, 124, 163, 156, 226, 181, 155, 187,
+			101, 201, 159, 24, 208, 89, 229, 169, 8, 216, 125, 115, 248, 229,
+			167, 212, 57, 122, 51, 249, 11, 148, 119, 125, 25, 196, 192, 134,
+			57, 197, 6, 121, 104, 58, 167, 100, 80, 30, 154, 206, 41, 25,
+			196, 192, 134, 185, 174, 151, 75, 25, 196, 186, 219, 121, 37, 131,
+			88, 241, 229, 229, 206, 9, 136, 16, 132, 149, 178, 128, 135, 148,
+			31, 69, 208, 16, 75, 14, 85, 53, 66, 124, 10, 120, 149, 166,
+			196, 119, 171, 187, 53, 69, 24, 41, 108, 229, 154, 138, 49, 82,
+			216, 118, 70, 181, 64, 137, 79, 97, 123, 90, 97, 12, 58, 255,
+			38, 114, 169, 17, 227, 107, 183, 190, 203, 146, 32, 177, 166, 253,
+			155, 216, 54, 240, 13, 33, 130, 241, 62, 94, 172, 219, 39, 185,
+			166, 170, 62, 1, 114, 32, 241, 21, 91, 1, 56, 144, 248, 74,
+			186, 0, 55, 144, 248, 29, 6, 84, 48, 198, 28, 159, 45, 202,
+			78, 105, 140, 64, 82, 81, 108, 85, 40, 130, 100, 209, 57, 77,
+			135, 0, 75, 169, 229, 21, 145, 215, 34, 228, 190, 168, 110, 47,
+			107, 55, 216, 13, 93, 191, 169, 196, 80, 34, 175, 136, 109, 3,
+			0, 69, 128, 80, 122, 37, 254, 141, 62, 247, 134, 102, 254, 57,
+			190, 103, 173, 201, 182, 123, 206, 10, 137, 146, 212, 194, 200, 43,
+			85, 191, 1, 66, 137, 188, 82, 137, 19, 32, 40, 145, 87, 42,
+			113, 114, 160, 223, 175, 236, 250, 13, 57, 171, 28, 213, 205, 251,
+			156, 20, 240, 193, 209, 124, 248, 13, 167, 151, 122, 26, 111, 233,
+			1, 132, 215, 185, 231, 127, 78, 48, 61, 170, 89, 0, 37, 161,
+			209, 114, 36, 136, 210, 3, 26, 45, 71, 130, 40, 61, 128, 186,
+			214, 210, 159, 73, 16, 40, 204, 156, 7, 17, 222, 224, 126, 255,
+			234, 208, 114, 194, 67, 244, 2, 49, 115, 174, 30, 50, 135, 214,
+			171, 93, 137, 81, 21, 24, 107, 67, 252, 28, 27, 58, 199, 1,
+			111, 165, 7, 3, 200, 41, 49, 72, 15, 106, 232, 28, 7, 188,
+			149, 30, 68, 235, 214, 211, 17, 9, 57, 245, 70, 20, 121, 4,
+			33, 247, 246, 250, 247, 75, 203, 149, 221, 67, 1, 218, 212, 27,
+			81, 44, 78, 111, 84, 104, 83, 206, 155, 16, 238, 113, 123, 184,
+			249, 252, 202, 16, 96, 45, 224, 159, 252, 38, 141, 4, 211, 2,
+			78, 167, 111, 66, 202, 69, 79, 194, 77, 189, 9, 109, 140, 107,
+			146, 8, 114, 231, 46, 137, 4, 3, 208, 81, 111, 249, 85, 66,
+			130, 145, 88, 79, 111, 9, 240, 121, 16, 180, 176, 77, 119, 71,
+			72, 239, 91, 208, 166, 110, 208, 36, 81, 22, 125, 187, 196, 153,
+			61, 214, 80, 149, 132, 14, 209, 154, 6, 145, 68, 17, 115, 222,
+			142, 98, 156, 30, 80, 192, 60, 209, 199, 16, 126, 39, 234, 115,
+			183, 213, 42, 147, 144, 89, 26, 96, 242, 180, 48, 231, 177, 0,
+			196, 70, 176, 254, 49, 13, 45, 36, 65, 120, 30, 211, 72, 95,
+			81, 112, 236, 123, 12, 117, 189, 83, 33, 125, 69, 149, 99, 223,
+			59, 52, 214, 106, 84, 59, 246, 189, 19, 41, 5, 2, 184, 61,
+			239, 254, 185, 43, 16, 9, 198, 243, 110, 173, 64, 36, 24, 207,
+			187, 181, 2, 145, 96, 60, 239, 22, 10, 228, 39, 18, 76, 8,
+			3, 162, 238, 6, 247, 187, 87, 13, 183, 21, 12, 204, 47, 90,
+			127, 212, 59, 129, 125, 33, 234, 35, 10, 234, 227, 201, 96, 204,
+			49, 32, 8, 43, 245, 17, 5, 245, 241, 164, 80, 31, 195, 18,
+			184, 232, 253, 40, 242, 255, 168, 149, 175, 129, 250, 88, 134, 188,
+			90, 80, 69, 239, 71, 177, 109, 224, 168, 14, 80, 69, 79, 33,
+			188, 215, 77, 4, 91, 114, 185, 255, 186, 162, 10, 105, 13, 99,
+			6, 183, 130, 10, 121, 10, 181, 175, 215, 36, 96, 6, 111, 216,
+			165, 73, 192, 12, 222, 211, 75, 31, 70, 26, 199, 232, 3, 66,
+			133, 220, 111, 84, 200, 47, 85, 131, 72, 40, 165, 15, 32, 3,
+			211, 132, 160, 129, 74, 131, 72, 40, 165, 15, 32, 240, 5, 5,
+			164, 164, 15, 161, 200, 199, 16, 114, 111, 105, 168, 65, 244, 89,
+			106, 211, 193, 136, 33, 230, 124, 8, 197, 54, 193, 68, 141, 137,
+			193, 248, 200, 207, 125, 162, 198, 64, 225, 124, 4, 133, 128, 137,
+			62, 162, 39, 170, 4, 38, 250, 136, 152, 168, 175, 80, 160, 67,
+			206, 71, 197, 60, 45, 92, 213, 52, 213, 189, 127, 161, 168, 120,
+			178, 41, 98, 72, 62, 170, 135, 68, 2, 169, 124, 84, 207, 18,
+			9, 164, 242, 81, 49, 75, 78, 73, 244, 134, 143, 163, 200, 167,
+			17, 114, 143, 52, 158, 37, 203, 26, 147, 54, 196, 156, 143, 163,
+			88, 55, 189, 65, 67, 37, 124, 18, 97, 238, 238, 10, 206, 213,
+			175, 56, 53, 218, 96, 106, 124, 18, 97, 27, 14, 225, 147, 26,
+			103, 77, 194, 33, 124, 82, 227, 172, 73, 56, 132, 79, 26, 156,
+			181, 54, 193, 242, 79, 253, 42, 173, 174, 109, 48, 16, 159, 210,
+			3, 33, 33, 21, 62, 165, 231, 134, 132, 84, 248, 20, 218, 212,
+			109, 124, 76, 190, 49, 72, 239, 184, 70, 119, 141, 69, 223, 43,
+			135, 125, 53, 194, 126, 38, 77, 60, 69, 174, 228, 101, 18, 255,
+			38, 162, 206, 89, 223, 43, 51, 102, 251, 26, 168, 76, 211, 219,
+			232, 10, 251, 84, 64, 93, 191, 182, 171, 103, 35, 226, 149, 141,
+			180, 5, 2, 244, 101, 30, 228, 227, 228, 107, 73, 50, 46, 159,
+			176, 3, 180, 75, 73, 85, 190, 144, 175, 44, 77, 41, 9, 84,
+			183, 225, 157, 246, 111, 195, 242, 39, 182, 159, 118, 21, 50, 126,
+			101, 234, 82, 222, 207, 87, 166, 42, 249, 121, 207, 175, 100, 230,
+			23, 54, 180, 192, 29, 52, 19, 191, 157, 19, 63, 77, 234, 95,
+			142, 236, 254, 86, 114, 103, 189, 132, 246, 108, 53, 220, 110, 236,
+			187, 23, 152, 151, 207, 221, 23, 127, 148, 210, 21, 226, 151, 9,
+			117, 112, 193, 14, 133, 188, 43, 182, 214, 113, 173, 176, 95, 87,
+			44, 25, 164, 109, 126, 190, 226, 77, 149, 75, 5, 157, 128, 59,
+			124, 193, 108, 127, 211, 63, 145, 175, 120, 227, 165, 130, 39, 249,
+			18, 243, 21, 201, 198, 232, 186, 66, 190, 120, 183, 151, 155, 242,
+			189, 108, 169, 152, 203, 148, 151, 166, 160, 189, 42, 77, 191, 219,
+			32, 77, 191, 40, 165, 75, 126, 57, 161, 63, 20, 207, 125, 118,
+			154, 182, 67, 187, 50, 217, 172, 231, 251, 48, 26, 237, 7, 119,
+			52, 111, 89, 18, 222, 149, 109, 163, 190, 121, 192, 94, 74, 59,
+			237, 232, 129, 169, 74, 57, 147, 175, 200, 204, 207, 171, 14, 38,
+			26, 151, 57, 98, 125, 52, 9, 223, 140, 179, 98, 205, 51, 54,
+			66, 87, 45, 148, 243, 151, 50, 217, 37, 93, 114, 20, 74, 238,
+			105, 92, 242, 152, 124, 95, 21, 186, 114, 193, 38, 217, 69, 186,
+			30, 186, 14, 243, 40, 147, 181, 155, 220, 10, 5, 239, 107, 206,
+			134, 116, 240, 157, 170, 96, 173, 95, 239, 177, 251, 20, 162, 52,
+			224, 27, 27, 160, 81, 121, 154, 9, 18, 180, 234, 224, 222, 229,
+			112, 187, 127, 2, 62, 25, 87, 159, 178, 117, 52, 90, 246, 50,
+			126, 73, 251, 54, 40, 42, 126, 27, 141, 78, 232, 55, 216, 196,
+			100, 114, 242, 236, 68, 85, 190, 236, 213, 180, 253, 196, 217, 161,
+			161, 169, 228, 192, 64, 106, 98, 162, 3, 49, 74, 163, 199, 147,
+			35, 35, 169, 193, 14, 28, 191, 149, 198, 180, 236, 177, 141, 116,
+			237, 68, 122, 50, 53, 53, 62, 58, 84, 157, 115, 155, 210, 232,
+			200, 232, 248, 112, 114, 168, 3, 177, 54, 218, 146, 28, 28, 78,
+			143, 116, 224, 248, 183, 16, 101, 181, 131, 201, 182, 211, 173, 118,
+			78, 238, 169, 201, 241, 100, 122, 178, 186, 89, 61, 116, 187, 202,
+			241, 61, 58, 50, 53, 122, 126, 36, 53, 56, 53, 58, 62, 53,
+			48, 48, 149, 158, 152, 56, 155, 154, 26, 56, 149, 28, 57, 153,
+			18, 205, 213, 165, 193, 139, 19, 147, 201, 241, 241, 212, 96, 213,
+			75, 152, 197, 233, 150, 218, 151, 212, 147, 193, 228, 100, 106, 162,
+			131, 176, 13, 180, 107, 96, 116, 120, 44, 57, 48, 57, 53, 113,
+			246, 248, 233, 212, 192, 228, 212, 80, 122, 36, 213, 225, 136, 42,
+			78, 14, 39, 211, 67, 83, 233, 145, 129, 161, 179, 131, 41, 85,
+			252, 80, 122, 228, 204, 212, 241, 179, 147, 147, 163, 35, 29, 45,
+			241, 227, 116, 101, 72, 188, 216, 22, 234, 142, 141, 167, 207, 37,
+			7, 46, 212, 239, 225, 26, 186, 114, 244, 248, 196, 192, 217, 241,
+			212, 148, 74, 88, 30, 127, 0, 209, 181, 117, 69, 73, 176, 3,
+			152, 159, 30, 153, 76, 141, 39, 7, 26, 243, 109, 23, 141, 143,
+			167, 198, 70, 199, 39, 167, 198, 83, 19, 147, 227, 233, 129, 201,
+			169, 115, 233, 212, 249, 169, 147, 163, 163, 39, 135, 84, 203, 5,
+			219, 214, 211, 206, 177, 179, 199, 135, 210, 154, 161, 48, 228, 227,
+			29, 248, 200, 205, 223, 74, 222, 208, 76, 129, 177, 117, 160, 97,
+			20, 101, 41, 198, 167, 9, 93, 5, 47, 102, 46, 121, 185, 59,
+			22, 189, 242, 210, 11, 93, 12, 186, 104, 11, 100, 59, 80, 42,
+			94, 18, 236, 38, 203, 243, 199, 209, 234, 173, 161, 119, 93, 224,
+			255, 115, 129, 174, 17, 139, 176, 14, 142, 154, 154, 47, 229, 60,
+			88, 9, 234, 42, 33, 211, 248, 254, 9, 235, 163, 225, 82, 206,
+			27, 239, 240, 171, 158, 196, 103, 105, 71, 245, 91, 108, 27, 221,
+			60, 113, 246, 248, 196, 192, 120, 122, 12, 70, 105, 120, 116, 176,
+			122, 186, 212, 201, 75, 143, 152, 75, 215, 165, 135, 135, 83, 131,
+			233, 228, 100, 170, 42, 103, 253, 145, 177, 111, 37, 135, 105, 119,
+			157, 49, 9, 88, 221, 87, 181, 78, 237, 243, 245, 111, 121, 207,
+			223, 119, 47, 80, 83, 50, 135, 132, 24, 172, 34, 109, 87, 156,
+			154, 168, 100, 234, 174, 218, 71, 206, 124, 43, 121, 138, 110, 172,
+			203, 93, 248, 102, 111, 117, 141, 11, 193, 143, 254, 190, 123, 181,
+			115, 168, 40, 235, 190, 235, 224, 210, 242, 87, 7, 105, 43, 107,
+			137, 69, 254, 8, 33, 250, 103, 24, 92, 90, 98, 255, 234, 93,
+			90, 14, 122, 50, 174, 4, 138, 202, 121, 179, 249, 162, 231, 115,
+			237, 85, 37, 47, 244, 164, 247, 133, 140, 48, 149, 192, 207, 51,
+			139, 190, 120, 207, 167, 92, 25, 123, 9, 0, 136, 75, 200, 87,
+			229, 127, 98, 111, 161, 163, 116, 77, 216, 13, 53, 158, 51, 43,
+			2, 207, 153, 21, 129, 231, 204, 42, 229, 35, 131, 24, 89, 29,
+			217, 67, 239, 144, 151, 99, 157, 145, 61, 200, 77, 9, 86, 150,
+			121, 217, 91, 16, 220, 45, 130, 159, 3, 212, 163, 206, 105, 130,
+			196, 39, 249, 102, 145, 210, 250, 122, 172, 51, 182, 66, 230, 214,
+			106, 101, 78, 23, 94, 239, 200, 155, 171, 214, 199, 99, 138, 124,
+			53, 209, 33, 0, 91, 48, 115, 127, 132, 235, 199, 80, 139, 6,
+			40, 244, 243, 228, 88, 90, 222, 90, 40, 112, 53, 181, 171, 131,
+			70, 75, 175, 121, 184, 159, 154, 85, 41, 88, 164, 104, 223, 170,
+			68, 251, 88, 63, 229, 167, 36, 248, 40, 148, 27, 20, 150, 205,
+			122, 11, 149, 6, 165, 248, 53, 197, 112, 61, 92, 251, 110, 5,
+			211, 246, 152, 4, 99, 133, 216, 35, 137, 159, 106, 223, 157, 233,
+			148, 159, 213, 133, 104, 92, 119, 237, 45, 147, 28, 75, 39, 40,
+			32, 25, 65, 142, 159, 76, 161, 192, 239, 5, 215, 250, 251, 78,
+			122, 21, 209, 180, 123, 253, 251, 100, 114, 59, 51, 58, 234, 130,
+			33, 167, 132, 71, 1, 243, 154, 49, 146, 160, 179, 178, 222, 3,
+			211, 128, 153, 36, 137, 131, 7, 246, 31, 186, 233, 134, 91, 110,
+			186, 241, 230, 233, 208, 189, 227, 150, 208, 189, 227, 150, 80, 236,
+			196, 150, 142, 53, 244, 94, 125, 143, 184, 13, 187, 174, 226, 122,
+			168, 175, 149, 18, 247, 231, 74, 151, 213, 129, 145, 108, 21, 228,
+			236, 81, 153, 83, 65, 102, 142, 47, 153, 176, 119, 152, 176, 121,
+			25, 180, 83, 154, 241, 179, 139, 101, 47, 7, 220, 45, 246, 25,
+			82, 34, 145, 153, 139, 196, 22, 81, 123, 204, 186, 86, 220, 214,
+			182, 214, 186, 86, 220, 182, 97, 35, 125, 25, 52, 19, 51, 178,
+			3, 39, 221, 59, 248, 104, 131, 130, 65, 174, 37, 156, 88, 73,
+			252, 177, 80, 89, 146, 183, 221, 121, 223, 154, 0, 148, 103, 66,
+			108, 54, 45, 193, 45, 162, 2, 221, 18, 193, 149, 29, 109, 171,
+			52, 69, 24, 217, 177, 134, 105, 42, 198, 200, 142, 206, 219, 213,
+			5, 39, 150, 151, 24, 59, 214, 30, 163, 35, 208, 80, 194, 200,
+			46, 28, 119, 147, 192, 207, 190, 203, 101, 177, 253, 44, 242, 124,
+			49, 167, 195, 117, 229, 52, 200, 151, 185, 189, 71, 18, 109, 190,
+			92, 42, 3, 152, 231, 92, 105, 177, 28, 220, 188, 146, 22, 81,
+			160, 110, 152, 152, 229, 187, 218, 54, 107, 74, 84, 198, 183, 193,
+			137, 44, 120, 92, 236, 198, 219, 220, 29, 220, 108, 160, 236, 41,
+			215, 227, 115, 177, 195, 226, 176, 251, 210, 133, 59, 45, 226, 155,
+			86, 77, 33, 70, 118, 199, 186, 53, 69, 24, 217, 189, 149, 211,
+			60, 220, 219, 182, 244, 71, 30, 66, 200, 125, 9, 183, 173, 14,
+			91, 179, 88, 170, 75, 94, 48, 103, 1, 192, 203, 92, 37, 67,
+			194, 56, 248, 186, 129, 162, 57, 28, 92, 232, 246, 199, 186, 100,
+			42, 187, 86, 230, 236, 195, 7, 165, 162, 65, 160, 104, 128, 76,
+			82, 199, 65, 66, 203, 221, 132, 111, 35, 238, 33, 62, 86, 170,
+			120, 69, 137, 207, 95, 42, 120, 170, 170, 197, 198, 85, 29, 82,
+			97, 53, 160, 213, 110, 106, 93, 77, 111, 164, 81, 65, 9, 213,
+			117, 216, 217, 226, 238, 226, 131, 203, 130, 187, 131, 219, 98, 53,
+			191, 14, 59, 27, 3, 26, 51, 114, 184, 123, 51, 29, 81, 197,
+			34, 70, 142, 56, 171, 221, 23, 241, 17, 161, 131, 164, 186, 149,
+			2, 11, 147, 186, 88, 50, 176, 201, 226, 151, 190, 203, 144, 147,
+			248, 158, 74, 57, 83, 149, 86, 81, 151, 143, 160, 64, 139, 198,
+			140, 28, 89, 185, 138, 238, 82, 245, 97, 70, 110, 117, 86, 185,
+			235, 249, 132, 41, 78, 58, 60, 9, 246, 88, 229, 136, 134, 221,
+			234, 180, 5, 180, 248, 112, 197, 74, 122, 70, 240, 151, 68, 152,
+			147, 196, 119, 16, 21, 206, 36, 247, 158, 129, 207, 209, 92, 198,
+			215, 248, 17, 77, 86, 15, 205, 105, 34, 88, 148, 164, 93, 244,
+			130, 104, 34, 137, 136, 193, 27, 116, 206, 180, 184, 105, 107, 240,
+			228, 174, 41, 24, 189, 30, 223, 212, 186, 156, 138, 160, 23, 162,
+			104, 196, 200, 96, 219, 106, 122, 51, 141, 73, 90, 12, 235, 137,
+			232, 230, 101, 15, 107, 7, 109, 211, 31, 34, 241, 229, 6, 251,
+			9, 102, 228, 196, 166, 110, 122, 200, 20, 14, 169, 180, 215, 185,
+			113, 9, 128, 162, 89, 83, 175, 225, 86, 49, 8, 190, 90, 99,
+			63, 129, 36, 220, 107, 233, 13, 166, 96, 204, 200, 233, 232, 26,
+			149, 176, 82, 251, 121, 205, 200, 252, 101, 1, 42, 79, 117, 201,
+			162, 61, 167, 163, 43, 236, 39, 162, 160, 213, 29, 244, 54, 201,
+			122, 193, 141, 17, 103, 157, 171, 14, 85, 13, 207, 45, 69, 81,
+			183, 241, 138, 189, 16, 131, 53, 226, 88, 52, 98, 100, 164, 125,
+			77, 64, 19, 70, 70, 186, 214, 210, 35, 170, 58, 196, 200, 152,
+			179, 206, 221, 11, 112, 143, 247, 44, 20, 50, 10, 244, 90, 7,
+			156, 41, 0, 240, 89, 227, 70, 107, 213, 37, 214, 135, 49, 171,
+			46, 193, 182, 49, 171, 46, 177, 70, 140, 117, 173, 5, 92, 51,
+			228, 32, 230, 156, 197, 89, 226, 14, 113, 216, 159, 241, 146, 202,
+			255, 42, 106, 10, 229, 243, 14, 164, 88, 29, 238, 54, 76, 67,
+			120, 84, 107, 10, 81, 243, 217, 214, 141, 74, 83, 64, 168, 223,
+			121, 103, 247, 85, 106, 10, 233, 132, 114, 222, 217, 30, 208, 152,
+			145, 243, 187, 122, 232, 152, 42, 22, 49, 114, 193, 73, 184, 73,
+			62, 225, 21, 117, 34, 235, 170, 182, 139, 222, 40, 172, 69, 233,
+			6, 162, 69, 67, 166, 11, 47, 149, 121, 54, 107, 213, 8, 190,
+			58, 78, 79, 64, 99, 70, 46, 244, 238, 165, 103, 84, 141, 152,
+			145, 187, 156, 221, 238, 173, 203, 171, 49, 52, 253, 85, 70, 56,
+			171, 50, 209, 254, 187, 172, 238, 65, 233, 187, 122, 232, 83, 72,
+			213, 70, 24, 121, 153, 211, 227, 190, 3, 201, 234, 114, 153, 138,
+			215, 7, 142, 72, 42, 101, 214, 213, 213, 187, 28, 200, 211, 237,
+			151, 231, 150, 250, 114, 249, 92, 95, 190, 239, 162, 87, 233, 203,
+			244, 205, 150, 10, 133, 210, 229, 169, 197, 133, 62, 120, 165, 207,
+			174, 211, 234, 138, 88, 105, 95, 230, 196, 3, 26, 51, 242, 178,
+			157, 187, 232, 205, 170, 39, 14, 35, 211, 206, 102, 119, 55, 79,
+			129, 205, 225, 47, 130, 85, 207, 11, 176, 25, 8, 48, 26, 33,
+			241, 10, 220, 129, 234, 130, 196, 42, 59, 237, 108, 8, 104, 204,
+			200, 244, 166, 110, 122, 76, 21, 220, 194, 200, 140, 179, 219, 221,
+			199, 211, 10, 146, 63, 195, 103, 22, 43, 149, 82, 81, 148, 173,
+			179, 0, 232, 20, 21, 249, 34, 63, 169, 44, 42, 93, 94, 11,
+			18, 5, 4, 99, 208, 130, 25, 153, 217, 213, 35, 67, 84, 29,
+			204, 156, 139, 184, 64, 220, 91, 185, 58, 23, 225, 149, 154, 185,
+			98, 89, 233, 13, 252, 102, 228, 156, 16, 163, 125, 177, 181, 75,
+			205, 9, 136, 248, 204, 59, 219, 175, 114, 78, 72, 143, 164, 188,
+			179, 37, 160, 49, 35, 249, 109, 113, 122, 94, 21, 139, 24, 185,
+			219, 89, 231, 158, 210, 166, 159, 173, 169, 164, 193, 7, 218, 176,
+			88, 42, 246, 105, 47, 150, 121, 111, 126, 70, 88, 171, 149, 185,
+			114, 105, 241, 34, 56, 233, 87, 107, 50, 229, 135, 116, 183, 179,
+			38, 160, 49, 35, 119, 3, 240, 171, 224, 20, 97, 206, 2, 126,
+			5, 113, 83, 176, 138, 114, 235, 0, 242, 234, 89, 102, 12, 14,
+			33, 85, 11, 173, 221, 138, 101, 68, 176, 172, 236, 36, 174, 146,
+			101, 210, 219, 168, 108, 38, 53, 1, 150, 149, 123, 247, 210, 143,
+			34, 85, 46, 98, 228, 146, 179, 215, 125, 63, 226, 201, 92, 142,
+			247, 140, 123, 126, 165, 156, 207, 86, 250, 206, 229, 189, 203, 125,
+			39, 97, 143, 219, 195, 11, 6, 79, 221, 64, 183, 132, 84, 35,
+			36, 52, 234, 167, 58, 221, 26, 108, 174, 32, 217, 209, 140, 7,
+			102, 36, 116, 175, 20, 124, 177, 91, 98, 222, 232, 44, 147, 123,
+			36, 0, 86, 176, 155, 145, 96, 83, 178, 246, 105, 203, 180, 177,
+			58, 134, 160, 225, 187, 2, 26, 51, 114, 105, 79, 47, 253, 55,
+			170, 95, 152, 145, 37, 167, 219, 157, 145, 55, 219, 151, 242, 222,
+			101, 128, 135, 88, 156, 41, 232, 84, 227, 126, 66, 110, 90, 50,
+			114, 169, 44, 43, 132, 234, 124, 49, 23, 52, 180, 88, 170, 0,
+			92, 249, 66, 201, 23, 6, 42, 164, 155, 191, 228, 217, 232, 81,
+			86, 147, 4, 47, 151, 156, 245, 1, 45, 218, 224, 110, 146, 70,
+			168, 24, 192, 251, 240, 75, 171, 1, 179, 237, 245, 84, 30, 73,
+			204, 73, 248, 38, 72, 212, 26, 242, 173, 148, 110, 138, 247, 133,
+			220, 20, 239, 11, 185, 41, 222, 215, 177, 198, 138, 237, 190, 143,
+			189, 36, 228, 165, 120, 95, 215, 139, 193, 254, 135, 181, 227, 85,
+			248, 164, 176, 255, 231, 60, 126, 177, 80, 154, 209, 150, 166, 176,
+			250, 170, 164, 84, 21, 136, 162, 226, 27, 67, 137, 18, 218, 187,
+			52, 69, 24, 121, 213, 250, 13, 154, 138, 49, 242, 170, 141, 58,
+			172, 92, 249, 229, 189, 106, 211, 32, 220, 174, 139, 85, 36, 250,
+			0, 194, 191, 137, 14, 186, 189, 181, 156, 144, 119, 26, 220, 220,
+			134, 72, 145, 48, 169, 150, 177, 3, 254, 88, 134, 4, 247, 44,
+			147, 106, 89, 122, 133, 177, 221, 154, 4, 247, 172, 189, 9, 149,
+			106, 25, 199, 68, 197, 125, 191, 137, 14, 168, 84, 203, 18, 48,
+			252, 213, 40, 58, 8, 94, 34, 72, 109, 208, 156, 223, 68, 209,
+			125, 180, 31, 218, 74, 152, 243, 26, 132, 207, 184, 220, 24, 86,
+			141, 76, 30, 89, 37, 137, 194, 7, 43, 52, 137, 4, 169, 176,
+			147, 16, 96, 39, 189, 6, 109, 234, 214, 100, 76, 144, 155, 79,
+			171, 218, 137, 172, 253, 53, 104, 235, 41, 122, 20, 106, 119, 152,
+			243, 90, 132, 111, 114, 251, 248, 72, 8, 196, 9, 84, 138, 189,
+			137, 10, 29, 151, 168, 210, 29, 249, 181, 33, 163, 130, 108, 223,
+			172, 73, 36, 200, 45, 7, 52, 73, 4, 121, 195, 141, 144, 73,
+			75, 172, 43, 206, 235, 17, 78, 184, 187, 171, 52, 255, 149, 234,
+			108, 113, 224, 67, 67, 70, 5, 217, 174, 187, 223, 130, 4, 185,
+			177, 71, 147, 68, 144, 189, 123, 1, 119, 17, 225, 40, 115, 222,
+			136, 240, 109, 238, 129, 70, 58, 244, 74, 149, 71, 29, 40, 193,
+			144, 80, 96, 59, 215, 36, 56, 136, 109, 59, 172, 73, 34, 200,
+			163, 183, 2, 50, 48, 102, 209, 71, 164, 55, 201, 9, 161, 92,
+			229, 178, 12, 64, 106, 58, 105, 49, 204, 203, 0, 44, 94, 239,
+			24, 155, 34, 158, 9, 105, 124, 4, 197, 214, 209, 149, 130, 104,
+			101, 209, 183, 34, 252, 118, 228, 128, 52, 98, 177, 65, 213, 15,
+			76, 10, 238, 119, 32, 204, 220, 29, 245, 212, 67, 222, 110, 208,
+			82, 144, 130, 187, 5, 62, 138, 105, 18, 9, 82, 65, 77, 202,
+			156, 219, 239, 64, 29, 107, 32, 3, 35, 76, 142, 119, 33, 236,
+			186, 55, 242, 65, 121, 114, 211, 176, 252, 4, 207, 123, 188, 167,
+			100, 37, 31, 54, 53, 162, 22, 40, 69, 215, 136, 160, 208, 182,
+			181, 154, 36, 130, 220, 176, 145, 238, 135, 26, 49, 248, 62, 117,
+			186, 113, 46, 19, 121, 242, 138, 87, 158, 111, 218, 33, 44, 221,
+			165, 116, 241, 210, 75, 75, 37, 229, 197, 48, 161, 223, 141, 214,
+			48, 208, 167, 130, 138, 190, 7, 225, 223, 65, 142, 123, 0, 60,
+			25, 68, 201, 38, 110, 0, 170, 144, 169, 155, 141, 131, 128, 104,
+			3, 228, 139, 48, 245, 17, 135, 57, 239, 9, 114, 152, 147, 22,
+			65, 42, 125, 130, 97, 250, 190, 71, 103, 248, 198, 48, 125, 223,
+			163, 51, 124, 99, 49, 125, 163, 239, 65, 91, 127, 7, 73, 112,
+			69, 241, 64, 232, 147, 39, 80, 52, 13, 231, 59, 216, 137, 176,
+			232, 251, 16, 126, 26, 17, 247, 24, 183, 175, 25, 248, 124, 41,
+			87, 151, 241, 13, 45, 2, 104, 128, 216, 173, 58, 239, 67, 173,
+			235, 97, 191, 132, 101, 232, 193, 191, 69, 206, 46, 183, 215, 216,
+			4, 201, 177, 244, 21, 236, 2, 104, 171, 220, 175, 138, 143, 183,
+			5, 15, 176, 120, 176, 99, 39, 189, 65, 149, 142, 152, 243, 123,
+			200, 217, 224, 238, 224, 131, 50, 52, 80, 93, 159, 72, 212, 238,
+			144, 253, 109, 149, 139, 228, 103, 157, 193, 3, 44, 30, 172, 91,
+			79, 251, 84, 185, 24, 210, 160, 111, 117, 55, 107, 166, 52, 47,
+			80, 180, 227, 247, 145, 227, 6, 15, 160, 128, 205, 210, 145, 31,
+			150, 132, 15, 32, 188, 71, 13, 146, 208, 115, 31, 64, 152, 105,
+			18, 60, 150, 58, 119, 104, 18, 60, 150, 122, 118, 211, 65, 153,
+			134, 254, 15, 81, 228, 143, 16, 114, 111, 226, 73, 227, 213, 108,
+			229, 181, 110, 62, 205, 15, 6, 121, 231, 255, 16, 197, 58, 97,
+			154, 147, 86, 22, 253, 48, 194, 255, 94, 77, 115, 2, 211, 92,
+			61, 56, 160, 157, 198, 63, 38, 166, 249, 246, 250, 86, 128, 117,
+			63, 211, 111, 231, 157, 255, 88, 144, 92, 87, 12, 220, 199, 244,
+			44, 151, 121, 231, 63, 134, 58, 214, 24, 71, 147, 7, 14, 208,
+			211, 215, 193, 209, 228, 133, 161, 224, 53, 113, 65, 185, 254, 222,
+			47, 205, 129, 85, 120, 245, 143, 178, 193, 243, 25, 255, 110, 229,
+			243, 114, 59, 93, 165, 78, 216, 53, 224, 65, 127, 200, 21, 164,
+			177, 247, 133, 198, 19, 57, 69, 187, 52, 8, 14, 184, 95, 232,
+			114, 246, 135, 225, 245, 154, 21, 164, 80, 247, 110, 167, 107, 171,
+			74, 82, 120, 9, 61, 180, 69, 122, 132, 72, 176, 132, 53, 53,
+			55, 158, 227, 242, 247, 248, 59, 16, 93, 115, 22, 64, 117, 237,
+			30, 37, 169, 35, 126, 134, 30, 213, 251, 250, 184, 11, 104, 34,
+			117, 155, 55, 14, 159, 178, 219, 105, 187, 149, 103, 70, 161, 155,
+			184, 53, 16, 1, 16, 66, 53, 156, 241, 239, 150, 240, 38, 84,
+			126, 35, 30, 196, 135, 41, 19, 18, 173, 175, 118, 85, 211, 110,
+			166, 173, 106, 210, 45, 15, 112, 67, 191, 29, 31, 165, 93, 103,
+			139, 215, 179, 192, 215, 35, 186, 222, 242, 133, 131, 11, 80, 93,
+			232, 193, 42, 84, 144, 102, 99, 121, 61, 32, 65, 30, 80, 16,
+			37, 225, 182, 40, 97, 184, 141, 174, 212, 215, 178, 144, 171, 91,
+			9, 197, 134, 122, 8, 26, 226, 203, 241, 21, 246, 157, 238, 178,
+			145, 52, 190, 74, 104, 139, 116, 39, 58, 74, 91, 149, 68, 178,
+			77, 213, 120, 23, 150, 152, 185, 181, 130, 21, 143, 176, 59, 233,
+			202, 144, 76, 179, 109, 117, 81, 20, 237, 153, 227, 198, 155, 189,
+			98, 112, 27, 146, 148, 6, 162, 206, 182, 132, 43, 175, 158, 3,
+			245, 27, 119, 154, 182, 91, 34, 196, 182, 134, 222, 169, 21, 46,
+			183, 33, 143, 227, 17, 54, 68, 87, 134, 4, 178, 170, 163, 245,
+			132, 181, 9, 198, 70, 134, 118, 84, 11, 64, 21, 18, 102, 3,
+			89, 117, 107, 241, 49, 234, 73, 209, 117, 1, 168, 120, 219, 14,
+			218, 202, 90, 156, 200, 239, 160, 127, 253, 0, 149, 219, 53, 198,
+			196, 138, 200, 93, 26, 99, 98, 165, 2, 161, 112, 24, 89, 21,
+			233, 165, 191, 103, 224, 38, 82, 200, 125, 17, 220, 93, 189, 0,
+			172, 137, 179, 114, 179, 251, 11, 2, 154, 88, 73, 223, 137, 52,
+			208, 196, 38, 188, 223, 125, 99, 195, 48, 228, 112, 240, 244, 89,
+			101, 20, 213, 15, 58, 246, 107, 98, 205, 175, 28, 99, 172, 114,
+			219, 218, 129, 236, 22, 106, 195, 166, 232, 74, 11, 181, 97, 211,
+			234, 205, 22, 106, 195, 166, 221, 9, 250, 111, 81, 128, 18, 57,
+			236, 62, 214, 52, 152, 186, 84, 29, 9, 174, 88, 222, 56, 132,
+			218, 220, 224, 27, 78, 54, 239, 143, 120, 31, 250, 163, 120, 95,
+			221, 35, 9, 193, 216, 105, 67, 48, 174, 221, 99, 67, 48, 222,
+			144, 166, 255, 27, 105, 108, 134, 4, 190, 201, 253, 187, 186, 113,
+			241, 82, 205, 249, 230, 162, 180, 121, 16, 120, 253, 241, 104, 8,
+			21, 16, 0, 87, 214, 226, 5, 200, 117, 221, 62, 198, 172, 207,
+			8, 81, 70, 190, 28, 100, 173, 19, 204, 128, 83, 188, 226, 69,
+			14, 88, 248, 230, 105, 53, 139, 196, 80, 38, 162, 29, 22, 172,
+			66, 130, 109, 183, 96, 21, 18, 253, 7, 255, 127, 234, 206, 167,
+			167, 137, 38, 12, 224, 59, 51, 221, 125, 247, 93, 18, 224, 93,
+			218, 2, 75, 223, 184, 7, 13, 254, 169, 45, 135, 158, 180, 137,
+			81, 55, 34, 88, 208, 16, 178, 224, 197, 180, 7, 77, 37, 64,
+			147, 118, 41, 49, 33, 21, 47, 166, 146, 216, 83, 189, 144, 38,
+			245, 27, 120, 49, 222, 188, 24, 63, 141, 223, 192, 155, 217, 249,
+			215, 121, 74, 233, 182, 36, 38, 122, 220, 3, 101, 159, 217, 157,
+			63, 59, 243, 252, 126, 143, 245, 77, 106, 21, 114, 248, 142, 243,
+			121, 96, 19, 209, 81, 207, 45, 241, 116, 116, 177, 192, 23, 187,
+			91, 60, 198, 74, 100, 219, 41, 218, 128, 139, 194, 243, 236, 239,
+			206, 161, 231, 195, 65, 38, 103, 252, 167, 152, 18, 114, 51, 87,
+			20, 83, 66, 110, 233, 182, 245, 93, 154, 18, 242, 120, 205, 249,
+			50, 248, 141, 216, 175, 253, 29, 1, 199, 144, 77, 242, 178, 11,
+			196, 176, 77, 242, 178, 11, 196, 136, 77, 242, 185, 21, 235, 171,
+			52, 36, 120, 216, 119, 62, 13, 12, 56, 156, 224, 100, 183, 150,
+			231, 192, 226, 3, 78, 26, 25, 71, 142, 118, 188, 65, 107, 84,
+			49, 130, 39, 37, 183, 58, 182, 137, 39, 37, 183, 58, 177, 137,
+			119, 107, 195, 122, 196, 178, 176, 86, 181, 199, 225, 204, 113, 158,
+			102, 159, 141, 83, 124, 41, 20, 109, 89, 93, 53, 147, 116, 19,
+			134, 238, 17, 20, 240, 122, 150, 31, 58, 159, 221, 113, 174, 40,
+			156, 70, 47, 33, 168, 0, 18, 130, 10, 32, 33, 168, 192, 105,
+			101, 38, 34, 40, 216, 235, 25, 224, 29, 88, 51, 60, 40, 38,
+			88, 55, 210, 52, 213, 12, 217, 177, 77, 237, 233, 176, 242, 120,
+			44, 72, 176, 232, 139, 70, 250, 55, 205, 20, 45, 102, 65, 145,
+			126, 31, 111, 103, 121, 49, 139, 254, 80, 107, 106, 172, 238, 221,
+			192, 221, 171, 212, 2, 90, 168, 106, 175, 244, 170, 191, 66, 85,
+			79, 172, 234, 3, 177, 170, 15, 196, 170, 62, 16, 171, 250, 206,
+			130, 226, 8, 240, 83, 219, 25, 176, 251, 190, 197, 219, 69, 58,
+			2, 182, 141, 180, 181, 193, 28, 1, 207, 180, 210, 208, 178, 100,
+			99, 55, 12, 166, 174, 131, 255, 173, 155, 194, 175, 90, 196, 142,
+			178, 139, 205, 119, 52, 15, 159, 87, 207, 134, 205, 68, 170, 69,
+			32, 82, 45, 78, 76, 41, 78, 128, 226, 116, 66, 113, 2, 20,
+			231, 230, 41, 210, 75, 184, 234, 224, 94, 196, 227, 237, 173, 207,
+			135, 91, 14, 8, 181, 28, 204, 211, 35, 54, 194, 45, 7, 203,
+			252, 229, 225, 93, 125, 164, 154, 248, 226, 53, 7, 181, 223, 57,
+			123, 111, 216, 164, 140, 13, 133, 203, 47, 255, 51, 161, 112, 249,
+			229, 201, 41, 133, 203, 47, 79, 239, 60, 128, 92, 254, 75, 35,
+			13, 184, 252, 29, 195, 163, 189, 142, 48, 221, 193, 22, 79, 200,
+			232, 43, 73, 63, 248, 94, 152, 247, 96, 65, 92, 133, 63, 144,
+			90, 20, 87, 196, 38, 187, 215, 111, 136, 43, 211, 38, 187, 105,
+			159, 223, 138, 240, 30, 100, 54, 169, 236, 36, 102, 199, 170, 218,
+			33, 114, 238, 71, 60, 5, 229, 139, 100, 248, 155, 20, 14, 214,
+			85, 211, 161, 135, 55, 212, 10, 16, 224, 122, 150, 215, 240, 129,
+			210, 102, 49, 207, 60, 17, 126, 128, 10, 29, 139, 129, 13, 32,
+			0, 54, 128, 224, 223, 105, 197, 6, 16, 204, 196, 21, 27, 64,
+			144, 168, 103, 128, 13, 224, 192, 88, 134, 54, 128, 58, 239, 63,
+			186, 173, 31, 105, 111, 80, 244, 192, 2, 190, 195, 134, 71, 29,
+			14, 220, 71, 102, 138, 150, 189, 210, 177, 102, 235, 13, 124, 140,
+			178, 60, 17, 38, 50, 236, 131, 125, 37, 112, 157, 6, 222, 224,
+			129, 83, 140, 156, 52, 120, 224, 148, 34, 39, 141, 153, 184, 53,
+			201, 174, 76, 91, 111, 36, 142, 17, 139, 92, 231, 145, 191, 54,
+			150, 233, 25, 145, 46, 56, 230, 99, 100, 164, 45, 159, 97, 219,
+			111, 145, 246, 1, 33, 231, 97, 68, 236, 253, 31, 131, 195, 171,
+			0, 26, 20, 221, 55, 47, 209, 202, 34, 20, 224, 110, 34, 124,
+			130, 178, 188, 90, 203, 224, 6, 224, 73, 23, 117, 94, 189, 169,
+			90, 163, 47, 190, 44, 145, 194, 160, 238, 38, 132, 186, 155, 16,
+			234, 110, 66, 168, 187, 137, 226, 39, 40, 3, 160, 238, 119, 226,
+			184, 78, 66, 221, 39, 97, 99, 116, 144, 160, 186, 91, 8, 39,
+			157, 247, 232, 55, 209, 162, 238, 202, 139, 240, 233, 10, 7, 83,
+			218, 45, 245, 230, 145, 37, 254, 235, 66, 174, 34, 242, 124, 1,
+			12, 222, 130, 48, 120, 11, 194, 224, 45, 20, 79, 88, 63, 37,
+			12, 222, 70, 120, 206, 249, 49, 30, 12, 174, 62, 228, 139, 146,
+			166, 227, 195, 224, 131, 254, 247, 222, 65, 45, 176, 162, 33, 240,
+			94, 104, 128, 4, 111, 67, 18, 188, 13, 73, 240, 54, 74, 206,
+			90, 91, 140, 4, 63, 69, 218, 71, 132, 156, 149, 168, 233, 115,
+			196, 46, 160, 48, 225, 167, 200, 116, 233, 73, 48, 101, 194, 59,
+			8, 95, 115, 92, 215, 43, 5, 165, 158, 147, 188, 111, 181, 9,
+			56, 240, 14, 228, 192, 59, 104, 34, 169, 112, 224, 29, 52, 123,
+			89, 225, 192, 59, 104, 241, 42, 131, 93, 41, 7, 222, 253, 147,
+			96, 87, 6, 130, 119, 33, 8, 222, 133, 32, 120, 87, 129, 93,
+			127, 5, 0, 0, 255, 255, 145, 3, 158, 143, 238, 2, 3, 0,
+		},
+	)
+}
+
+// FileDescriptorSet returns a descriptor set for this proto package, which
+// includes all defined services, and all transitive dependencies.
+//
+// Will not return nil.
+//
+// Do NOT modify the returned descriptor.
+func FileDescriptorSet() *descriptorpb.FileDescriptorSet {
+	// We just need ONE of the service names to look up the FileDescriptorSet.
+	ret, err := discovery.GetDescriptorSet("monorail.v3.Frontend")
+	if err != nil {
+		panic(err)
+	}
+	return ret
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/permission_objects.pb.go b/analysis/internal/bugs/monorail/api_proto/permission_objects.pb.go
new file mode 100644
index 0000000..8dfb2ed
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/permission_objects.pb.go
@@ -0,0 +1,258 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for features and related business
+// objects, e.g., hotlists.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/permission_objects.proto
+
+package api_proto
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// All possible permissions on the Monorail site.
+// Next available tag: 6
+type Permission int32
+
+const (
+	// Default value. This value is unused.
+	Permission_PERMISSION_UNSPECIFIED Permission = 0
+	// The permission needed to add and remove issues from a hotlist.
+	Permission_HOTLIST_EDIT Permission = 1
+	// The permission needed to delete a hotlist or change hotlist
+	// settings/members.
+	Permission_HOTLIST_ADMINISTER Permission = 2
+	// The permission needed to edit an issue.
+	Permission_ISSUE_EDIT Permission = 3
+	// The permission needed to edit a custom field definition.
+	Permission_FIELD_DEF_EDIT Permission = 4
+	// The permission needed to edit the value of a custom field.
+	// More permissions will be required in the specific issue
+	// where the user plans to edit that value, e.g. ISSUE_EDIT.
+	Permission_FIELD_DEF_VALUE_EDIT Permission = 5
+)
+
+// Enum value maps for Permission.
+var (
+	Permission_name = map[int32]string{
+		0: "PERMISSION_UNSPECIFIED",
+		1: "HOTLIST_EDIT",
+		2: "HOTLIST_ADMINISTER",
+		3: "ISSUE_EDIT",
+		4: "FIELD_DEF_EDIT",
+		5: "FIELD_DEF_VALUE_EDIT",
+	}
+	Permission_value = map[string]int32{
+		"PERMISSION_UNSPECIFIED": 0,
+		"HOTLIST_EDIT":           1,
+		"HOTLIST_ADMINISTER":     2,
+		"ISSUE_EDIT":             3,
+		"FIELD_DEF_EDIT":         4,
+		"FIELD_DEF_VALUE_EDIT":   5,
+	}
+)
+
+func (x Permission) Enum() *Permission {
+	p := new(Permission)
+	*p = x
+	return p
+}
+
+func (x Permission) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Permission) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_enumTypes[0].Descriptor()
+}
+
+func (Permission) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_enumTypes[0]
+}
+
+func (x Permission) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Permission.Descriptor instead.
+func (Permission) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescGZIP(), []int{0}
+}
+
+// The set of a user's permissions for a single resource.
+// Next available tag: 3
+type PermissionSet struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the resource `permissions` applies to.
+	Resource string `protobuf:"bytes,1,opt,name=resource,proto3" json:"resource,omitempty"`
+	// All the permissions a user has for `resource`.
+	Permissions []Permission `protobuf:"varint,2,rep,packed,name=permissions,proto3,enum=monorail.v3.Permission" json:"permissions,omitempty"`
+}
+
+func (x *PermissionSet) Reset() {
+	*x = PermissionSet{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PermissionSet) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PermissionSet) ProtoMessage() {}
+
+func (x *PermissionSet) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use PermissionSet.ProtoReflect.Descriptor instead.
+func (*PermissionSet) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *PermissionSet) GetResource() string {
+	if x != nil {
+		return x.Resource
+	}
+	return ""
+}
+
+func (x *PermissionSet) GetPermissions() []Permission {
+	if x != nil {
+		return x.Permissions
+	}
+	return nil
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDesc = []byte{
+	0x0a, 0x57, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6f, 0x62, 0x6a, 0x65,
+	0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x22, 0x66, 0x0a, 0x0d, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73,
+	0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x72, 0x65, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x12, 0x39, 0x0a, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f,
+	0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f,
+	0x6e, 0x52, 0x0b, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2a, 0x90,
+	0x01, 0x0a, 0x0a, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a,
+	0x16, 0x50, 0x45, 0x52, 0x4d, 0x49, 0x53, 0x53, 0x49, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50,
+	0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x48, 0x4f, 0x54,
+	0x4c, 0x49, 0x53, 0x54, 0x5f, 0x45, 0x44, 0x49, 0x54, 0x10, 0x01, 0x12, 0x16, 0x0a, 0x12, 0x48,
+	0x4f, 0x54, 0x4c, 0x49, 0x53, 0x54, 0x5f, 0x41, 0x44, 0x4d, 0x49, 0x4e, 0x49, 0x53, 0x54, 0x45,
+	0x52, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x49, 0x53, 0x53, 0x55, 0x45, 0x5f, 0x45, 0x44, 0x49,
+	0x54, 0x10, 0x03, 0x12, 0x12, 0x0a, 0x0e, 0x46, 0x49, 0x45, 0x4c, 0x44, 0x5f, 0x44, 0x45, 0x46,
+	0x5f, 0x45, 0x44, 0x49, 0x54, 0x10, 0x04, 0x12, 0x18, 0x0a, 0x14, 0x46, 0x49, 0x45, 0x4c, 0x44,
+	0x5f, 0x44, 0x45, 0x46, 0x5f, 0x56, 0x41, 0x4c, 0x55, 0x45, 0x5f, 0x45, 0x44, 0x49, 0x54, 0x10,
+	0x05, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d,
+	0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73,
+	0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73,
+	0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_goTypes = []interface{}{
+	(Permission)(0),       // 0: monorail.v3.Permission
+	(*PermissionSet)(nil), // 1: monorail.v3.PermissionSet
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_depIdxs = []int32{
+	0, // 0: monorail.v3.PermissionSet.permissions:type_name -> monorail.v3.Permission
+	1, // [1:1] is the sub-list for method output_type
+	1, // [1:1] is the sub-list for method input_type
+	1, // [1:1] is the sub-list for extension type_name
+	1, // [1:1] is the sub-list for extension extendee
+	0, // [0:1] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*PermissionSet); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDesc,
+			NumEnums:      1,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_depIdxs,
+		EnumInfos:         file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_enumTypes,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_depIdxs = nil
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/permission_objects.proto b/analysis/internal/bugs/monorail/api_proto/permission_objects.proto
new file mode 100644
index 0000000..343e61e
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/permission_objects.proto
@@ -0,0 +1,43 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for features and related business
+// objects, e.g., hotlists.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+// All possible permissions on the Monorail site.
+// Next available tag: 6
+enum Permission {
+  // Default value. This value is unused.
+  PERMISSION_UNSPECIFIED = 0;
+  // The permission needed to add and remove issues from a hotlist.
+  HOTLIST_EDIT = 1;
+  // The permission needed to delete a hotlist or change hotlist
+  // settings/members.
+  HOTLIST_ADMINISTER = 2;
+  // The permission needed to edit an issue.
+  ISSUE_EDIT = 3;
+  // The permission needed to edit a custom field definition.
+  FIELD_DEF_EDIT = 4;
+  // The permission needed to edit the value of a custom field.
+  // More permissions will be required in the specific issue
+  // where the user plans to edit that value, e.g. ISSUE_EDIT.
+  FIELD_DEF_VALUE_EDIT = 5;
+}
+
+
+// The set of a user's permissions for a single resource.
+// Next available tag: 3
+message PermissionSet {
+  // The name of the resource `permissions` applies to.
+  string resource = 1;
+  // All the permissions a user has for `resource`.
+  repeated Permission permissions = 2;
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/permissions.pb.go b/analysis/internal/bugs/monorail/api_proto/permissions.pb.go
new file mode 100644
index 0000000..e466775
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/permissions.pb.go
@@ -0,0 +1,502 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/permissions.proto
+
+package api_proto
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Request message for the GetPermissionSet emthod.
+// Next available tag: 2
+type GetPermissionSetRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name of the resource permissions to retrieve.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetPermissionSetRequest) Reset() {
+	*x = GetPermissionSetRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetPermissionSetRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetPermissionSetRequest) ProtoMessage() {}
+
+func (x *GetPermissionSetRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetPermissionSetRequest.ProtoReflect.Descriptor instead.
+func (*GetPermissionSetRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GetPermissionSetRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// Request message for the BatchGetPermissionSets method.
+// Next available tag: 2
+type BatchGetPermissionSetsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource names of the resource permissions to retrieve.
+	Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"`
+}
+
+func (x *BatchGetPermissionSetsRequest) Reset() {
+	*x = BatchGetPermissionSetsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetPermissionSetsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetPermissionSetsRequest) ProtoMessage() {}
+
+func (x *BatchGetPermissionSetsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetPermissionSetsRequest.ProtoReflect.Descriptor instead.
+func (*BatchGetPermissionSetsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *BatchGetPermissionSetsRequest) GetNames() []string {
+	if x != nil {
+		return x.Names
+	}
+	return nil
+}
+
+// Response message for the BatchGetPermissionSets method.
+// Next available tag: 2
+type BatchGetPermissionSetsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The Permissions, one for each of the given resources.
+	PermissionSets []*PermissionSet `protobuf:"bytes,1,rep,name=permission_sets,json=permissionSets,proto3" json:"permission_sets,omitempty"`
+}
+
+func (x *BatchGetPermissionSetsResponse) Reset() {
+	*x = BatchGetPermissionSetsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetPermissionSetsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetPermissionSetsResponse) ProtoMessage() {}
+
+func (x *BatchGetPermissionSetsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetPermissionSetsResponse.ProtoReflect.Descriptor instead.
+func (*BatchGetPermissionSetsResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *BatchGetPermissionSetsResponse) GetPermissionSets() []*PermissionSet {
+	if x != nil {
+		return x.PermissionSets
+	}
+	return nil
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDesc = []byte{
+	0x0a, 0x50, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x1a,
+	0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c,
+	0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x1a, 0x57, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6f, 0x62, 0x6a, 0x65,
+	0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x32, 0x0a, 0x17, 0x47, 0x65, 0x74,
+	0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x3a, 0x0a,
+	0x1d, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19,
+	0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x42, 0x03, 0xe0,
+	0x41, 0x02, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x65, 0x0a, 0x1e, 0x42, 0x61, 0x74,
+	0x63, 0x68, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53,
+	0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x43, 0x0a, 0x0f, 0x70,
+	0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x73, 0x65, 0x74, 0x73, 0x18, 0x01,
+	0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74,
+	0x52, 0x0e, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x73,
+	0x32, 0xda, 0x01, 0x0a, 0x0b, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x73,
+	0x12, 0x56, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f,
+	0x6e, 0x53, 0x65, 0x74, 0x12, 0x24, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+	0x53, 0x65, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x22, 0x00, 0x12, 0x73, 0x0a, 0x16, 0x42, 0x61, 0x74, 0x63,
+	0x68, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53, 0x65,
+	0x74, 0x73, 0x12, 0x2a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x53, 0x65, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x42, 0x61, 0x74,
+	0x63, 0x68, 0x47, 0x65, 0x74, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x53,
+	0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x40, 0x5a,
+	0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67,
+	0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69,
+	0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_goTypes = []interface{}{
+	(*GetPermissionSetRequest)(nil),        // 0: monorail.v3.GetPermissionSetRequest
+	(*BatchGetPermissionSetsRequest)(nil),  // 1: monorail.v3.BatchGetPermissionSetsRequest
+	(*BatchGetPermissionSetsResponse)(nil), // 2: monorail.v3.BatchGetPermissionSetsResponse
+	(*PermissionSet)(nil),                  // 3: monorail.v3.PermissionSet
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_depIdxs = []int32{
+	3, // 0: monorail.v3.BatchGetPermissionSetsResponse.permission_sets:type_name -> monorail.v3.PermissionSet
+	0, // 1: monorail.v3.Permissions.GetPermissionSet:input_type -> monorail.v3.GetPermissionSetRequest
+	1, // 2: monorail.v3.Permissions.BatchGetPermissionSets:input_type -> monorail.v3.BatchGetPermissionSetsRequest
+	3, // 3: monorail.v3.Permissions.GetPermissionSet:output_type -> monorail.v3.PermissionSet
+	2, // 4: monorail.v3.Permissions.BatchGetPermissionSets:output_type -> monorail.v3.BatchGetPermissionSetsResponse
+	3, // [3:5] is the sub-list for method output_type
+	1, // [1:3] is the sub-list for method input_type
+	1, // [1:1] is the sub-list for extension type_name
+	1, // [1:1] is the sub-list for extension extendee
+	0, // [0:1] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetPermissionSetRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetPermissionSetsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetPermissionSetsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   3,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_depIdxs,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permissions_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// PermissionsClient is the client API for Permissions service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type PermissionsClient interface {
+	// status: DO NOT USE
+	// Returns the requester's permissions for the given resource.
+	//
+	// Raises:
+	//  PERMISSION_DENIED if the given resource does not exist and/or the
+	//      requester does not have permission to view the resource's name space.
+	//  NOT_FOUND if the given resource does not exist.
+	GetPermissionSet(ctx context.Context, in *GetPermissionSetRequest, opts ...grpc.CallOption) (*PermissionSet, error)
+	// status: DO NOT USE
+	// Returns the requester's permissions for all the given resources.
+	//
+	// Raises:
+	//  PERMISSION_DENIED if any of the given resources do not exist and/or the
+	//      requester does not have permission to view one of the resource's
+	//      name space.
+	// NOT_FOUND if one of the given resources do not exist.
+	BatchGetPermissionSets(ctx context.Context, in *BatchGetPermissionSetsRequest, opts ...grpc.CallOption) (*BatchGetPermissionSetsResponse, error)
+}
+type permissionsPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewPermissionsPRPCClient(client *prpc.Client) PermissionsClient {
+	return &permissionsPRPCClient{client}
+}
+
+func (c *permissionsPRPCClient) GetPermissionSet(ctx context.Context, in *GetPermissionSetRequest, opts ...grpc.CallOption) (*PermissionSet, error) {
+	out := new(PermissionSet)
+	err := c.client.Call(ctx, "monorail.v3.Permissions", "GetPermissionSet", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *permissionsPRPCClient) BatchGetPermissionSets(ctx context.Context, in *BatchGetPermissionSetsRequest, opts ...grpc.CallOption) (*BatchGetPermissionSetsResponse, error) {
+	out := new(BatchGetPermissionSetsResponse)
+	err := c.client.Call(ctx, "monorail.v3.Permissions", "BatchGetPermissionSets", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type permissionsClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewPermissionsClient(cc grpc.ClientConnInterface) PermissionsClient {
+	return &permissionsClient{cc}
+}
+
+func (c *permissionsClient) GetPermissionSet(ctx context.Context, in *GetPermissionSetRequest, opts ...grpc.CallOption) (*PermissionSet, error) {
+	out := new(PermissionSet)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Permissions/GetPermissionSet", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *permissionsClient) BatchGetPermissionSets(ctx context.Context, in *BatchGetPermissionSetsRequest, opts ...grpc.CallOption) (*BatchGetPermissionSetsResponse, error) {
+	out := new(BatchGetPermissionSetsResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Permissions/BatchGetPermissionSets", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// PermissionsServer is the server API for Permissions service.
+type PermissionsServer interface {
+	// status: DO NOT USE
+	// Returns the requester's permissions for the given resource.
+	//
+	// Raises:
+	//  PERMISSION_DENIED if the given resource does not exist and/or the
+	//      requester does not have permission to view the resource's name space.
+	//  NOT_FOUND if the given resource does not exist.
+	GetPermissionSet(context.Context, *GetPermissionSetRequest) (*PermissionSet, error)
+	// status: DO NOT USE
+	// Returns the requester's permissions for all the given resources.
+	//
+	// Raises:
+	//  PERMISSION_DENIED if any of the given resources do not exist and/or the
+	//      requester does not have permission to view one of the resource's
+	//      name space.
+	// NOT_FOUND if one of the given resources do not exist.
+	BatchGetPermissionSets(context.Context, *BatchGetPermissionSetsRequest) (*BatchGetPermissionSetsResponse, error)
+}
+
+// UnimplementedPermissionsServer can be embedded to have forward compatible implementations.
+type UnimplementedPermissionsServer struct {
+}
+
+func (*UnimplementedPermissionsServer) GetPermissionSet(context.Context, *GetPermissionSetRequest) (*PermissionSet, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetPermissionSet not implemented")
+}
+func (*UnimplementedPermissionsServer) BatchGetPermissionSets(context.Context, *BatchGetPermissionSetsRequest) (*BatchGetPermissionSetsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method BatchGetPermissionSets not implemented")
+}
+
+func RegisterPermissionsServer(s prpc.Registrar, srv PermissionsServer) {
+	s.RegisterService(&_Permissions_serviceDesc, srv)
+}
+
+func _Permissions_GetPermissionSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetPermissionSetRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(PermissionsServer).GetPermissionSet(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Permissions/GetPermissionSet",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(PermissionsServer).GetPermissionSet(ctx, req.(*GetPermissionSetRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Permissions_BatchGetPermissionSets_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(BatchGetPermissionSetsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(PermissionsServer).BatchGetPermissionSets(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Permissions/BatchGetPermissionSets",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(PermissionsServer).BatchGetPermissionSets(ctx, req.(*BatchGetPermissionSetsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Permissions_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "monorail.v3.Permissions",
+	HandlerType: (*PermissionsServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetPermissionSet",
+			Handler:    _Permissions_GetPermissionSet_Handler,
+		},
+		{
+			MethodName: "BatchGetPermissionSets",
+			Handler:    _Permissions_BatchGetPermissionSets_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/permissions.proto",
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/permissions.proto b/analysis/internal/bugs/monorail/api_proto/permissions.proto
new file mode 100644
index 0000000..7eb2023
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/permissions.proto
@@ -0,0 +1,61 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/api/field_behavior.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/permission_objects.proto";
+
+// ***DO NOT CALL rpcs IN THIS SERVICE.***
+// This service is for Monorail's frontend only.
+
+// Permissions service includes all methods needed for fetching permissions.
+service Permissions {
+  // status: DO NOT USE
+  // Returns the requester's permissions for the given resource.
+  //
+  // Raises:
+  //  PERMISSION_DENIED if the given resource does not exist and/or the
+  //      requester does not have permission to view the resource's name space.
+  //  NOT_FOUND if the given resource does not exist.
+  rpc GetPermissionSet (GetPermissionSetRequest) returns (PermissionSet) {}
+
+  // status: DO NOT USE
+  // Returns the requester's permissions for all the given resources.
+  //
+  // Raises:
+  //  PERMISSION_DENIED if any of the given resources do not exist and/or the
+  //      requester does not have permission to view one of the resource's
+  //      name space.
+  // NOT_FOUND if one of the given resources do not exist.
+  rpc BatchGetPermissionSets (BatchGetPermissionSetsRequest) returns (BatchGetPermissionSetsResponse) {}
+}
+
+
+// Request message for the GetPermissionSet emthod.
+// Next available tag: 2
+message GetPermissionSetRequest {
+  // The resource name of the resource permissions to retrieve.
+  string name = 1 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// Request message for the BatchGetPermissionSets method.
+// Next available tag: 2
+message BatchGetPermissionSetsRequest {
+  // The resource names of the resource permissions to retrieve.
+  repeated string names = 1 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// Response message for the BatchGetPermissionSets method.
+// Next available tag: 2
+message BatchGetPermissionSetsResponse {
+  // The Permissions, one for each of the given resources.
+  repeated PermissionSet permission_sets = 1;
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/project_objects.pb.go b/analysis/internal/bugs/monorail/api_proto/project_objects.pb.go
new file mode 100644
index 0000000..a2e5862
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/project_objects.pb.go
@@ -0,0 +1,3017 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for projects and their resources.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/project_objects.proto
+
+package api_proto
+
+import (
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Type of this status.
+// Next available tag: 4
+type StatusDef_StatusDefType int32
+
+const (
+	// Default enum value. This value is unused.
+	StatusDef_STATUS_DEF_TYPE_UNSPECIFIED StatusDef_StatusDefType = 0
+	// This status means issue is open.
+	StatusDef_OPEN StatusDef_StatusDefType = 1
+	// This status means issue is closed.
+	StatusDef_CLOSED StatusDef_StatusDefType = 2
+	// This status means issue is merged into another.
+	StatusDef_MERGED StatusDef_StatusDefType = 3
+)
+
+// Enum value maps for StatusDef_StatusDefType.
+var (
+	StatusDef_StatusDefType_name = map[int32]string{
+		0: "STATUS_DEF_TYPE_UNSPECIFIED",
+		1: "OPEN",
+		2: "CLOSED",
+		3: "MERGED",
+	}
+	StatusDef_StatusDefType_value = map[string]int32{
+		"STATUS_DEF_TYPE_UNSPECIFIED": 0,
+		"OPEN":                        1,
+		"CLOSED":                      2,
+		"MERGED":                      3,
+	}
+)
+
+func (x StatusDef_StatusDefType) Enum() *StatusDef_StatusDefType {
+	p := new(StatusDef_StatusDefType)
+	*p = x
+	return p
+}
+
+func (x StatusDef_StatusDefType) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (StatusDef_StatusDefType) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[0].Descriptor()
+}
+
+func (StatusDef_StatusDefType) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[0]
+}
+
+func (x StatusDef_StatusDefType) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use StatusDef_StatusDefType.Descriptor instead.
+func (StatusDef_StatusDefType) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{1, 0}
+}
+
+// State of this status.
+// Next available tag: 3
+type StatusDef_StatusDefState int32
+
+const (
+	// Default value. This value is unused.
+	StatusDef_STATUS_DEF_STATE_UNSPECIFIED StatusDef_StatusDefState = 0
+	// This status is deprecated
+	StatusDef_DEPRECATED StatusDef_StatusDefState = 1
+	// This status is not deprecated
+	StatusDef_ACTIVE StatusDef_StatusDefState = 2
+)
+
+// Enum value maps for StatusDef_StatusDefState.
+var (
+	StatusDef_StatusDefState_name = map[int32]string{
+		0: "STATUS_DEF_STATE_UNSPECIFIED",
+		1: "DEPRECATED",
+		2: "ACTIVE",
+	}
+	StatusDef_StatusDefState_value = map[string]int32{
+		"STATUS_DEF_STATE_UNSPECIFIED": 0,
+		"DEPRECATED":                   1,
+		"ACTIVE":                       2,
+	}
+)
+
+func (x StatusDef_StatusDefState) Enum() *StatusDef_StatusDefState {
+	p := new(StatusDef_StatusDefState)
+	*p = x
+	return p
+}
+
+func (x StatusDef_StatusDefState) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (StatusDef_StatusDefState) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[1].Descriptor()
+}
+
+func (StatusDef_StatusDefState) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[1]
+}
+
+func (x StatusDef_StatusDefState) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use StatusDef_StatusDefState.Descriptor instead.
+func (StatusDef_StatusDefState) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{1, 1}
+}
+
+// State of this label.
+// Next available tag: 3
+type LabelDef_LabelDefState int32
+
+const (
+	// Default enum value. This value is unused.
+	LabelDef_LABEL_DEF_STATE_UNSPECIFIED LabelDef_LabelDefState = 0
+	// This label is deprecated
+	LabelDef_DEPRECATED LabelDef_LabelDefState = 1
+	// This label is not deprecated
+	LabelDef_ACTIVE LabelDef_LabelDefState = 2
+)
+
+// Enum value maps for LabelDef_LabelDefState.
+var (
+	LabelDef_LabelDefState_name = map[int32]string{
+		0: "LABEL_DEF_STATE_UNSPECIFIED",
+		1: "DEPRECATED",
+		2: "ACTIVE",
+	}
+	LabelDef_LabelDefState_value = map[string]int32{
+		"LABEL_DEF_STATE_UNSPECIFIED": 0,
+		"DEPRECATED":                  1,
+		"ACTIVE":                      2,
+	}
+)
+
+func (x LabelDef_LabelDefState) Enum() *LabelDef_LabelDefState {
+	p := new(LabelDef_LabelDefState)
+	*p = x
+	return p
+}
+
+func (x LabelDef_LabelDefState) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (LabelDef_LabelDefState) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[2].Descriptor()
+}
+
+func (LabelDef_LabelDefState) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[2]
+}
+
+func (x LabelDef_LabelDefState) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use LabelDef_LabelDefState.Descriptor instead.
+func (LabelDef_LabelDefState) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{2, 0}
+}
+
+// Type of this field.
+// Next available tag: 7
+type FieldDef_Type int32
+
+const (
+	// Default enum value. This value is unused.
+	FieldDef_TYPE_UNSPECIFIED FieldDef_Type = 0
+	// This field can be filled only with enumerated option(s).
+	FieldDef_ENUM FieldDef_Type = 1
+	// This field can be filled with integer(s).
+	FieldDef_INT FieldDef_Type = 2
+	// This field can be filled with string(s).
+	FieldDef_STR FieldDef_Type = 3
+	// This field can be filled with user(s).
+	FieldDef_USER FieldDef_Type = 4
+	// This field can be filled with date(s).
+	FieldDef_DATE FieldDef_Type = 5
+	// This field can be filled with URL(s).
+	FieldDef_URL FieldDef_Type = 6
+)
+
+// Enum value maps for FieldDef_Type.
+var (
+	FieldDef_Type_name = map[int32]string{
+		0: "TYPE_UNSPECIFIED",
+		1: "ENUM",
+		2: "INT",
+		3: "STR",
+		4: "USER",
+		5: "DATE",
+		6: "URL",
+	}
+	FieldDef_Type_value = map[string]int32{
+		"TYPE_UNSPECIFIED": 0,
+		"ENUM":             1,
+		"INT":              2,
+		"STR":              3,
+		"USER":             4,
+		"DATE":             5,
+		"URL":              6,
+	}
+)
+
+func (x FieldDef_Type) Enum() *FieldDef_Type {
+	p := new(FieldDef_Type)
+	*p = x
+	return p
+}
+
+func (x FieldDef_Type) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FieldDef_Type) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[3].Descriptor()
+}
+
+func (FieldDef_Type) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[3]
+}
+
+func (x FieldDef_Type) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FieldDef_Type.Descriptor instead.
+func (FieldDef_Type) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 0}
+}
+
+// Traits of this field, ie is required or can support multiple values.
+// Next available tag: 6
+type FieldDef_Traits int32
+
+const (
+	// Default enum value. This value is unused.
+	FieldDef_TRAITS_UNSPECIFIED FieldDef_Traits = 0
+	// This field must be filled out in issues where it's applicable.
+	FieldDef_REQUIRED FieldDef_Traits = 1
+	// This field defaults to hidden.
+	FieldDef_DEFAULT_HIDDEN FieldDef_Traits = 2
+	// This field can have multiple values.
+	FieldDef_MULTIVALUED FieldDef_Traits = 3
+	// This is a phase field, meaning it is repeated for each phase of an
+	// approval process. It cannot be the child of a particular approval.
+	FieldDef_PHASE FieldDef_Traits = 4
+	// Values of this field can only be edited in issues/templates by editors.
+	// Project owners and field admins are not subject of this restriction.
+	FieldDef_RESTRICTED FieldDef_Traits = 5
+)
+
+// Enum value maps for FieldDef_Traits.
+var (
+	FieldDef_Traits_name = map[int32]string{
+		0: "TRAITS_UNSPECIFIED",
+		1: "REQUIRED",
+		2: "DEFAULT_HIDDEN",
+		3: "MULTIVALUED",
+		4: "PHASE",
+		5: "RESTRICTED",
+	}
+	FieldDef_Traits_value = map[string]int32{
+		"TRAITS_UNSPECIFIED": 0,
+		"REQUIRED":           1,
+		"DEFAULT_HIDDEN":     2,
+		"MULTIVALUED":        3,
+		"PHASE":              4,
+		"RESTRICTED":         5,
+	}
+)
+
+func (x FieldDef_Traits) Enum() *FieldDef_Traits {
+	p := new(FieldDef_Traits)
+	*p = x
+	return p
+}
+
+func (x FieldDef_Traits) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FieldDef_Traits) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[4].Descriptor()
+}
+
+func (FieldDef_Traits) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[4]
+}
+
+func (x FieldDef_Traits) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FieldDef_Traits.Descriptor instead.
+func (FieldDef_Traits) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 1}
+}
+
+// Event that triggers a notification.
+// Next available tag: 3
+type FieldDef_UserTypeSettings_NotifyTriggers int32
+
+const (
+	// Default notify trigger value. This value is unused.
+	FieldDef_UserTypeSettings_NOTIFY_TRIGGERS_UNSPECIFIED FieldDef_UserTypeSettings_NotifyTriggers = 0
+	// There are no notifications.
+	FieldDef_UserTypeSettings_NEVER FieldDef_UserTypeSettings_NotifyTriggers = 1
+	// Notify whenever any comment is made.
+	FieldDef_UserTypeSettings_ANY_COMMENT FieldDef_UserTypeSettings_NotifyTriggers = 2
+)
+
+// Enum value maps for FieldDef_UserTypeSettings_NotifyTriggers.
+var (
+	FieldDef_UserTypeSettings_NotifyTriggers_name = map[int32]string{
+		0: "NOTIFY_TRIGGERS_UNSPECIFIED",
+		1: "NEVER",
+		2: "ANY_COMMENT",
+	}
+	FieldDef_UserTypeSettings_NotifyTriggers_value = map[string]int32{
+		"NOTIFY_TRIGGERS_UNSPECIFIED": 0,
+		"NEVER":                       1,
+		"ANY_COMMENT":                 2,
+	}
+)
+
+func (x FieldDef_UserTypeSettings_NotifyTriggers) Enum() *FieldDef_UserTypeSettings_NotifyTriggers {
+	p := new(FieldDef_UserTypeSettings_NotifyTriggers)
+	*p = x
+	return p
+}
+
+func (x FieldDef_UserTypeSettings_NotifyTriggers) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FieldDef_UserTypeSettings_NotifyTriggers) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[5].Descriptor()
+}
+
+func (FieldDef_UserTypeSettings_NotifyTriggers) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[5]
+}
+
+func (x FieldDef_UserTypeSettings_NotifyTriggers) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FieldDef_UserTypeSettings_NotifyTriggers.Descriptor instead.
+func (FieldDef_UserTypeSettings_NotifyTriggers) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 3, 0}
+}
+
+// Field value(s) can only be set to users that fulfill the role
+// requirements.
+// Next available tag: 3
+type FieldDef_UserTypeSettings_RoleRequirements int32
+
+const (
+	// Default role requirement value. This value is unused.
+	FieldDef_UserTypeSettings_ROLE_REQUIREMENTS_UNSPECIFIED FieldDef_UserTypeSettings_RoleRequirements = 0
+	// There is no requirement.
+	FieldDef_UserTypeSettings_NO_ROLE_REQUIREMENT FieldDef_UserTypeSettings_RoleRequirements = 1
+	// Field value(s) can only be set to users who are members.
+	FieldDef_UserTypeSettings_PROJECT_MEMBER FieldDef_UserTypeSettings_RoleRequirements = 2
+)
+
+// Enum value maps for FieldDef_UserTypeSettings_RoleRequirements.
+var (
+	FieldDef_UserTypeSettings_RoleRequirements_name = map[int32]string{
+		0: "ROLE_REQUIREMENTS_UNSPECIFIED",
+		1: "NO_ROLE_REQUIREMENT",
+		2: "PROJECT_MEMBER",
+	}
+	FieldDef_UserTypeSettings_RoleRequirements_value = map[string]int32{
+		"ROLE_REQUIREMENTS_UNSPECIFIED": 0,
+		"NO_ROLE_REQUIREMENT":           1,
+		"PROJECT_MEMBER":                2,
+	}
+)
+
+func (x FieldDef_UserTypeSettings_RoleRequirements) Enum() *FieldDef_UserTypeSettings_RoleRequirements {
+	p := new(FieldDef_UserTypeSettings_RoleRequirements)
+	*p = x
+	return p
+}
+
+func (x FieldDef_UserTypeSettings_RoleRequirements) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FieldDef_UserTypeSettings_RoleRequirements) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[6].Descriptor()
+}
+
+func (FieldDef_UserTypeSettings_RoleRequirements) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[6]
+}
+
+func (x FieldDef_UserTypeSettings_RoleRequirements) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FieldDef_UserTypeSettings_RoleRequirements.Descriptor instead.
+func (FieldDef_UserTypeSettings_RoleRequirements) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 3, 1}
+}
+
+// Action to do when a date field value arrives.
+// Next available tag: 4
+type FieldDef_DateTypeSettings_DateAction int32
+
+const (
+	// Default date action value. This value is unused.
+	FieldDef_DateTypeSettings_DATE_ACTION_UNSPECIFIED FieldDef_DateTypeSettings_DateAction = 0
+	// No action will be taken when a date arrives.
+	FieldDef_DateTypeSettings_NO_ACTION FieldDef_DateTypeSettings_DateAction = 1
+	// Notify owner only when a date arrives.
+	FieldDef_DateTypeSettings_NOTIFY_OWNER FieldDef_DateTypeSettings_DateAction = 2
+	// Notify all participants when a date arrives.
+	FieldDef_DateTypeSettings_NOTIFY_PARTICIPANTS FieldDef_DateTypeSettings_DateAction = 3
+)
+
+// Enum value maps for FieldDef_DateTypeSettings_DateAction.
+var (
+	FieldDef_DateTypeSettings_DateAction_name = map[int32]string{
+		0: "DATE_ACTION_UNSPECIFIED",
+		1: "NO_ACTION",
+		2: "NOTIFY_OWNER",
+		3: "NOTIFY_PARTICIPANTS",
+	}
+	FieldDef_DateTypeSettings_DateAction_value = map[string]int32{
+		"DATE_ACTION_UNSPECIFIED": 0,
+		"NO_ACTION":               1,
+		"NOTIFY_OWNER":            2,
+		"NOTIFY_PARTICIPANTS":     3,
+	}
+)
+
+func (x FieldDef_DateTypeSettings_DateAction) Enum() *FieldDef_DateTypeSettings_DateAction {
+	p := new(FieldDef_DateTypeSettings_DateAction)
+	*p = x
+	return p
+}
+
+func (x FieldDef_DateTypeSettings_DateAction) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (FieldDef_DateTypeSettings_DateAction) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[7].Descriptor()
+}
+
+func (FieldDef_DateTypeSettings_DateAction) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[7]
+}
+
+func (x FieldDef_DateTypeSettings_DateAction) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use FieldDef_DateTypeSettings_DateAction.Descriptor instead.
+func (FieldDef_DateTypeSettings_DateAction) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 4, 0}
+}
+
+// The current state of the component definition.
+// Next available tag: 3
+type ComponentDef_ComponentDefState int32
+
+const (
+	// Default enum value. This value is unused.
+	ComponentDef_COMPONENT_DEF_STATE_UNSPECIFIED ComponentDef_ComponentDefState = 0
+	// This component is deprecated
+	ComponentDef_DEPRECATED ComponentDef_ComponentDefState = 1
+	// This component is not deprecated
+	ComponentDef_ACTIVE ComponentDef_ComponentDefState = 2
+)
+
+// Enum value maps for ComponentDef_ComponentDefState.
+var (
+	ComponentDef_ComponentDefState_name = map[int32]string{
+		0: "COMPONENT_DEF_STATE_UNSPECIFIED",
+		1: "DEPRECATED",
+		2: "ACTIVE",
+	}
+	ComponentDef_ComponentDefState_value = map[string]int32{
+		"COMPONENT_DEF_STATE_UNSPECIFIED": 0,
+		"DEPRECATED":                      1,
+		"ACTIVE":                          2,
+	}
+)
+
+func (x ComponentDef_ComponentDefState) Enum() *ComponentDef_ComponentDefState {
+	p := new(ComponentDef_ComponentDefState)
+	*p = x
+	return p
+}
+
+func (x ComponentDef_ComponentDefState) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ComponentDef_ComponentDefState) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[8].Descriptor()
+}
+
+func (ComponentDef_ComponentDefState) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[8]
+}
+
+func (x ComponentDef_ComponentDefState) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ComponentDef_ComponentDefState.Descriptor instead.
+func (ComponentDef_ComponentDefState) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{4, 0}
+}
+
+// Visibility permission of template.
+// Next available tag: 3
+type IssueTemplate_TemplatePrivacy int32
+
+const (
+	// This value is unused.
+	IssueTemplate_TEMPLATE_PRIVACY_UNSPECIFIED IssueTemplate_TemplatePrivacy = 0
+	// Owner project members may view this template.
+	IssueTemplate_MEMBERS_ONLY IssueTemplate_TemplatePrivacy = 1
+	// Anyone on the web can view this template.
+	IssueTemplate_PUBLIC IssueTemplate_TemplatePrivacy = 2
+)
+
+// Enum value maps for IssueTemplate_TemplatePrivacy.
+var (
+	IssueTemplate_TemplatePrivacy_name = map[int32]string{
+		0: "TEMPLATE_PRIVACY_UNSPECIFIED",
+		1: "MEMBERS_ONLY",
+		2: "PUBLIC",
+	}
+	IssueTemplate_TemplatePrivacy_value = map[string]int32{
+		"TEMPLATE_PRIVACY_UNSPECIFIED": 0,
+		"MEMBERS_ONLY":                 1,
+		"PUBLIC":                       2,
+	}
+)
+
+func (x IssueTemplate_TemplatePrivacy) Enum() *IssueTemplate_TemplatePrivacy {
+	p := new(IssueTemplate_TemplatePrivacy)
+	*p = x
+	return p
+}
+
+func (x IssueTemplate_TemplatePrivacy) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (IssueTemplate_TemplatePrivacy) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[9].Descriptor()
+}
+
+func (IssueTemplate_TemplatePrivacy) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[9]
+}
+
+func (x IssueTemplate_TemplatePrivacy) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use IssueTemplate_TemplatePrivacy.Descriptor instead.
+func (IssueTemplate_TemplatePrivacy) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{7, 0}
+}
+
+// Indicator of who if anyone should be the default owner of the issue
+// created with this template.
+// Next available tag: 2
+type IssueTemplate_DefaultOwner int32
+
+const (
+	// There is no default owner.
+	// This value is used if the default owner is omitted.
+	IssueTemplate_DEFAULT_OWNER_UNSPECIFIED IssueTemplate_DefaultOwner = 0
+	// The owner should default to the Issue reporter if the reporter is a
+	// member of the project.
+	IssueTemplate_PROJECT_MEMBER_REPORTER IssueTemplate_DefaultOwner = 1
+)
+
+// Enum value maps for IssueTemplate_DefaultOwner.
+var (
+	IssueTemplate_DefaultOwner_name = map[int32]string{
+		0: "DEFAULT_OWNER_UNSPECIFIED",
+		1: "PROJECT_MEMBER_REPORTER",
+	}
+	IssueTemplate_DefaultOwner_value = map[string]int32{
+		"DEFAULT_OWNER_UNSPECIFIED": 0,
+		"PROJECT_MEMBER_REPORTER":   1,
+	}
+)
+
+func (x IssueTemplate_DefaultOwner) Enum() *IssueTemplate_DefaultOwner {
+	p := new(IssueTemplate_DefaultOwner)
+	*p = x
+	return p
+}
+
+func (x IssueTemplate_DefaultOwner) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (IssueTemplate_DefaultOwner) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[10].Descriptor()
+}
+
+func (IssueTemplate_DefaultOwner) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[10]
+}
+
+func (x IssueTemplate_DefaultOwner) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use IssueTemplate_DefaultOwner.Descriptor instead.
+func (IssueTemplate_DefaultOwner) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{7, 1}
+}
+
+// The role the user has in the project.
+// Next available tag: 4
+type ProjectMember_ProjectRole int32
+
+const (
+	// The user has no role in the project.
+	ProjectMember_PROJECT_ROLE_UNSPECIFIED ProjectMember_ProjectRole = 0
+	// The user can make any changes to the project.
+	ProjectMember_OWNER ProjectMember_ProjectRole = 1
+	// The user may participate in the project but may not edit the project.
+	ProjectMember_COMMITTER ProjectMember_ProjectRole = 2
+	// The user starts with the same permissions as a non-member.
+	ProjectMember_CONTRIBUTOR ProjectMember_ProjectRole = 3
+)
+
+// Enum value maps for ProjectMember_ProjectRole.
+var (
+	ProjectMember_ProjectRole_name = map[int32]string{
+		0: "PROJECT_ROLE_UNSPECIFIED",
+		1: "OWNER",
+		2: "COMMITTER",
+		3: "CONTRIBUTOR",
+	}
+	ProjectMember_ProjectRole_value = map[string]int32{
+		"PROJECT_ROLE_UNSPECIFIED": 0,
+		"OWNER":                    1,
+		"COMMITTER":                2,
+		"CONTRIBUTOR":              3,
+	}
+)
+
+func (x ProjectMember_ProjectRole) Enum() *ProjectMember_ProjectRole {
+	p := new(ProjectMember_ProjectRole)
+	*p = x
+	return p
+}
+
+func (x ProjectMember_ProjectRole) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ProjectMember_ProjectRole) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[11].Descriptor()
+}
+
+func (ProjectMember_ProjectRole) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[11]
+}
+
+func (x ProjectMember_ProjectRole) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ProjectMember_ProjectRole.Descriptor instead.
+func (ProjectMember_ProjectRole) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{9, 0}
+}
+
+// Whether the user should show up in autocomplete.
+// Next available tag: 3
+type ProjectMember_AutocompleteVisibility int32
+
+const (
+	// No autocomplete visibility value specified.
+	ProjectMember_AUTOCOMPLETE_VISIBILITY_UNSPECIFIED ProjectMember_AutocompleteVisibility = 0
+	// The user should not show up in autocomplete.
+	ProjectMember_HIDDEN ProjectMember_AutocompleteVisibility = 1
+	// The user may show up in autocomplete.
+	ProjectMember_SHOWN ProjectMember_AutocompleteVisibility = 2
+)
+
+// Enum value maps for ProjectMember_AutocompleteVisibility.
+var (
+	ProjectMember_AutocompleteVisibility_name = map[int32]string{
+		0: "AUTOCOMPLETE_VISIBILITY_UNSPECIFIED",
+		1: "HIDDEN",
+		2: "SHOWN",
+	}
+	ProjectMember_AutocompleteVisibility_value = map[string]int32{
+		"AUTOCOMPLETE_VISIBILITY_UNSPECIFIED": 0,
+		"HIDDEN":                              1,
+		"SHOWN":                               2,
+	}
+)
+
+func (x ProjectMember_AutocompleteVisibility) Enum() *ProjectMember_AutocompleteVisibility {
+	p := new(ProjectMember_AutocompleteVisibility)
+	*p = x
+	return p
+}
+
+func (x ProjectMember_AutocompleteVisibility) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ProjectMember_AutocompleteVisibility) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[12].Descriptor()
+}
+
+func (ProjectMember_AutocompleteVisibility) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes[12]
+}
+
+func (x ProjectMember_AutocompleteVisibility) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ProjectMember_AutocompleteVisibility.Descriptor instead.
+func (ProjectMember_AutocompleteVisibility) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{9, 1}
+}
+
+// The top level organization of issues in Monorail.
+//
+// See monorail/doc/userguide/concepts.md#Projects-and-roles.
+// and monorail/doc/userguide/project-owners.md#why-does-monorail-have-projects
+// Next available tag: 5
+type Project struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the project.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Display name of the project.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Summary of the project, ie describing what its use and purpose.
+	Summary string `protobuf:"bytes,3,opt,name=summary,proto3" json:"summary,omitempty"`
+	// URL pointing to this project's logo image.
+	ThumbnailUrl string `protobuf:"bytes,4,opt,name=thumbnail_url,json=thumbnailUrl,proto3" json:"thumbnail_url,omitempty"`
+}
+
+func (x *Project) Reset() {
+	*x = Project{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Project) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Project) ProtoMessage() {}
+
+func (x *Project) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Project.ProtoReflect.Descriptor instead.
+func (*Project) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Project) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Project) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *Project) GetSummary() string {
+	if x != nil {
+		return x.Summary
+	}
+	return ""
+}
+
+func (x *Project) GetThumbnailUrl() string {
+	if x != nil {
+		return x.ThumbnailUrl
+	}
+	return ""
+}
+
+// Potential steps along the development process that an issue can be in.
+//
+// See monorail/doc/userguide/project-owners.md#How-to-configure-statuses
+// (-- aip.dev/not-precedent: "Status" should be reserved for HTTP/gRPC codes
+//     per aip.dev/216. Monorail's Status  preceded the AIP standards, and is
+//     used extensively throughout the system.)
+// Next available tag: 7
+type StatusDef struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the status.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// String value of the status.
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+	// Type of this status.
+	Type StatusDef_StatusDefType `protobuf:"varint,3,opt,name=type,proto3,enum=monorail.v3.StatusDef_StatusDefType" json:"type,omitempty"`
+	// Sorting rank of this status. If we sort issues by status
+	// this rank determines the sort order rather than status value.
+	Rank uint32 `protobuf:"varint,4,opt,name=rank,proto3" json:"rank,omitempty"`
+	// Brief explanation of this status.
+	Docstring string `protobuf:"bytes,5,opt,name=docstring,proto3" json:"docstring,omitempty"`
+	// State of this status.
+	State StatusDef_StatusDefState `protobuf:"varint,6,opt,name=state,proto3,enum=monorail.v3.StatusDef_StatusDefState" json:"state,omitempty"`
+}
+
+func (x *StatusDef) Reset() {
+	*x = StatusDef{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *StatusDef) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StatusDef) ProtoMessage() {}
+
+func (x *StatusDef) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use StatusDef.ProtoReflect.Descriptor instead.
+func (*StatusDef) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *StatusDef) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *StatusDef) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+func (x *StatusDef) GetType() StatusDef_StatusDefType {
+	if x != nil {
+		return x.Type
+	}
+	return StatusDef_STATUS_DEF_TYPE_UNSPECIFIED
+}
+
+func (x *StatusDef) GetRank() uint32 {
+	if x != nil {
+		return x.Rank
+	}
+	return 0
+}
+
+func (x *StatusDef) GetDocstring() string {
+	if x != nil {
+		return x.Docstring
+	}
+	return ""
+}
+
+func (x *StatusDef) GetState() StatusDef_StatusDefState {
+	if x != nil {
+		return x.State
+	}
+	return StatusDef_STATUS_DEF_STATE_UNSPECIFIED
+}
+
+// Well-known labels that can be applied to issues within the project.
+//
+// See monorail/doc/userguide/concepts.md#issue-fields-and-labels.
+// Next available tag: 5
+// Labels defined in this project.
+type LabelDef struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the label.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// String value of the label.
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+	// Brief explanation of this label.
+	Docstring string `protobuf:"bytes,3,opt,name=docstring,proto3" json:"docstring,omitempty"`
+	// State of this label.
+	State LabelDef_LabelDefState `protobuf:"varint,4,opt,name=state,proto3,enum=monorail.v3.LabelDef_LabelDefState" json:"state,omitempty"`
+}
+
+func (x *LabelDef) Reset() {
+	*x = LabelDef{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *LabelDef) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*LabelDef) ProtoMessage() {}
+
+func (x *LabelDef) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use LabelDef.ProtoReflect.Descriptor instead.
+func (*LabelDef) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *LabelDef) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *LabelDef) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+func (x *LabelDef) GetDocstring() string {
+	if x != nil {
+		return x.Docstring
+	}
+	return ""
+}
+
+func (x *LabelDef) GetState() LabelDef_LabelDefState {
+	if x != nil {
+		return x.State
+	}
+	return LabelDef_LABEL_DEF_STATE_UNSPECIFIED
+}
+
+// Custom fields defined for the project.
+//
+// See monorail/doc/userguide/concepts.md#issue-fields-and-labels.
+// Check bugs.chromium.org/p/{project}/adminLabels to see the FieldDef IDs.
+// If your code needs to call multiple monorail instances
+// (e.g. monorail-{prod|staging|dev}) FieldDef IDs for FieldDefs
+// with the same display_name will differ between each monorail
+// instance. To see what FieldDef ID to use when calling staging
+// you must check bugs-staging.chromium.org/p/{project}/adminLabels.
+// Next available tag: 15
+type FieldDef struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the field.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Display name of the field.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Brief explanation of this field.
+	Docstring string        `protobuf:"bytes,3,opt,name=docstring,proto3" json:"docstring,omitempty"`
+	Type      FieldDef_Type `protobuf:"varint,4,opt,name=type,proto3,enum=monorail.v3.FieldDef_Type" json:"type,omitempty"`
+	// Type of issue this field applies: ie Bug or Enhancement.
+	// Note: type is indicated by any "Type-foo" label or "Type" custom field.
+	ApplicableIssueType string `protobuf:"bytes,5,opt,name=applicable_issue_type,json=applicableIssueType,proto3" json:"applicable_issue_type,omitempty"`
+	// Administrators of this field.
+	Admins []string          `protobuf:"bytes,6,rep,name=admins,proto3" json:"admins,omitempty"`
+	Traits []FieldDef_Traits `protobuf:"varint,7,rep,packed,name=traits,proto3,enum=monorail.v3.FieldDef_Traits" json:"traits,omitempty"`
+	// ApprovalDef that this field belongs to, if applicable.
+	// A field may not both have `approval_parent` set and have the PHASE trait.
+	ApprovalParent string                     `protobuf:"bytes,8,opt,name=approval_parent,json=approvalParent,proto3" json:"approval_parent,omitempty"`
+	EnumSettings   *FieldDef_EnumTypeSettings `protobuf:"bytes,9,opt,name=enum_settings,json=enumSettings,proto3" json:"enum_settings,omitempty"`
+	IntSettings    *FieldDef_IntTypeSettings  `protobuf:"bytes,10,opt,name=int_settings,json=intSettings,proto3" json:"int_settings,omitempty"`
+	StrSettings    *FieldDef_StrTypeSettings  `protobuf:"bytes,11,opt,name=str_settings,json=strSettings,proto3" json:"str_settings,omitempty"`
+	UserSettings   *FieldDef_UserTypeSettings `protobuf:"bytes,12,opt,name=user_settings,json=userSettings,proto3" json:"user_settings,omitempty"`
+	DateSettings   *FieldDef_DateTypeSettings `protobuf:"bytes,13,opt,name=date_settings,json=dateSettings,proto3" json:"date_settings,omitempty"`
+	// Editors of this field, only for RESTRICTED fields.
+	Editors []string `protobuf:"bytes,14,rep,name=editors,proto3" json:"editors,omitempty"`
+}
+
+func (x *FieldDef) Reset() {
+	*x = FieldDef{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef) ProtoMessage() {}
+
+func (x *FieldDef) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef.ProtoReflect.Descriptor instead.
+func (*FieldDef) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *FieldDef) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *FieldDef) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *FieldDef) GetDocstring() string {
+	if x != nil {
+		return x.Docstring
+	}
+	return ""
+}
+
+func (x *FieldDef) GetType() FieldDef_Type {
+	if x != nil {
+		return x.Type
+	}
+	return FieldDef_TYPE_UNSPECIFIED
+}
+
+func (x *FieldDef) GetApplicableIssueType() string {
+	if x != nil {
+		return x.ApplicableIssueType
+	}
+	return ""
+}
+
+func (x *FieldDef) GetAdmins() []string {
+	if x != nil {
+		return x.Admins
+	}
+	return nil
+}
+
+func (x *FieldDef) GetTraits() []FieldDef_Traits {
+	if x != nil {
+		return x.Traits
+	}
+	return nil
+}
+
+func (x *FieldDef) GetApprovalParent() string {
+	if x != nil {
+		return x.ApprovalParent
+	}
+	return ""
+}
+
+func (x *FieldDef) GetEnumSettings() *FieldDef_EnumTypeSettings {
+	if x != nil {
+		return x.EnumSettings
+	}
+	return nil
+}
+
+func (x *FieldDef) GetIntSettings() *FieldDef_IntTypeSettings {
+	if x != nil {
+		return x.IntSettings
+	}
+	return nil
+}
+
+func (x *FieldDef) GetStrSettings() *FieldDef_StrTypeSettings {
+	if x != nil {
+		return x.StrSettings
+	}
+	return nil
+}
+
+func (x *FieldDef) GetUserSettings() *FieldDef_UserTypeSettings {
+	if x != nil {
+		return x.UserSettings
+	}
+	return nil
+}
+
+func (x *FieldDef) GetDateSettings() *FieldDef_DateTypeSettings {
+	if x != nil {
+		return x.DateSettings
+	}
+	return nil
+}
+
+func (x *FieldDef) GetEditors() []string {
+	if x != nil {
+		return x.Editors
+	}
+	return nil
+}
+
+// A high level definition of the part of the software affected by an issue.
+//
+// See monorail/doc/userguide/project-owners.md#how-to-configure-components.
+// Check crbug.com/p/{project}/adminComponents to see the ComponenttDef IDs.
+// Next available tag: 12
+type ComponentDef struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the component, aka identifier.
+	// the API will always return ComponentDef names with format:
+	// projects/{project}/componentDefs/<component_def_id>.
+	// However the API will accept ComponentDef names with formats:
+	// projects/{project}/componentDefs/<component_def_id|value>.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// String value of the component, ie 'Tools>Stability' or 'Blink'.
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+	// Brief explanation of this component.
+	Docstring string `protobuf:"bytes,3,opt,name=docstring,proto3" json:"docstring,omitempty"`
+	// Administrators of this component.
+	Admins []string `protobuf:"bytes,4,rep,name=admins,proto3" json:"admins,omitempty"`
+	// Auto cc'ed users of this component.
+	Ccs []string `protobuf:"bytes,5,rep,name=ccs,proto3" json:"ccs,omitempty"`
+	// State of this component.
+	State ComponentDef_ComponentDefState `protobuf:"varint,6,opt,name=state,proto3,enum=monorail.v3.ComponentDef_ComponentDefState" json:"state,omitempty"`
+	// The user that created this component.
+	Creator string `protobuf:"bytes,7,opt,name=creator,proto3" json:"creator,omitempty"`
+	// The user that last modified this component.
+	Modifier string `protobuf:"bytes,8,opt,name=modifier,proto3" json:"modifier,omitempty"`
+	// The time this component was created.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+	// The time this component was last modified.
+	ModifyTime *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=modify_time,json=modifyTime,proto3" json:"modify_time,omitempty"`
+	// Labels that auto-apply to issues in this component.
+	Labels []string `protobuf:"bytes,11,rep,name=labels,proto3" json:"labels,omitempty"`
+}
+
+func (x *ComponentDef) Reset() {
+	*x = ComponentDef{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ComponentDef) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ComponentDef) ProtoMessage() {}
+
+func (x *ComponentDef) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ComponentDef.ProtoReflect.Descriptor instead.
+func (*ComponentDef) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ComponentDef) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ComponentDef) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+func (x *ComponentDef) GetDocstring() string {
+	if x != nil {
+		return x.Docstring
+	}
+	return ""
+}
+
+func (x *ComponentDef) GetAdmins() []string {
+	if x != nil {
+		return x.Admins
+	}
+	return nil
+}
+
+func (x *ComponentDef) GetCcs() []string {
+	if x != nil {
+		return x.Ccs
+	}
+	return nil
+}
+
+func (x *ComponentDef) GetState() ComponentDef_ComponentDefState {
+	if x != nil {
+		return x.State
+	}
+	return ComponentDef_COMPONENT_DEF_STATE_UNSPECIFIED
+}
+
+func (x *ComponentDef) GetCreator() string {
+	if x != nil {
+		return x.Creator
+	}
+	return ""
+}
+
+func (x *ComponentDef) GetModifier() string {
+	if x != nil {
+		return x.Modifier
+	}
+	return ""
+}
+
+func (x *ComponentDef) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+func (x *ComponentDef) GetModifyTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.ModifyTime
+	}
+	return nil
+}
+
+func (x *ComponentDef) GetLabels() []string {
+	if x != nil {
+		return x.Labels
+	}
+	return nil
+}
+
+// Defines approvals that issues within the project may need.
+// See monorail/doc/userguide/concepts.md#issue-approvals-and-gates and
+// monorail/doc/userguide/project-owners.md#How-to-configure-approvals
+// Check bugs.chromium.org/p/{project}/adminLabels to see the ApprovalDef IDs.
+// If your code needs to call multiple monorail instances
+// (e.g. monorail-{prod|staging|dev}) ApprovalDef IDs for ApprovalDefs
+// with the same display_name will differ between each monorail
+// instance. To see what ApprovalDef ID to use when calling staging
+// you must check bugs-staging.chromium.org/p/{project}/adminLabels.
+// Next available tag: 7
+type ApprovalDef struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the approval.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Display name of the field.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Brief explanation of this field.
+	Docstring string `protobuf:"bytes,3,opt,name=docstring,proto3" json:"docstring,omitempty"`
+	// Information approvers need from requester.
+	// May be adjusted on the issue after creation.
+	Survey string `protobuf:"bytes,4,opt,name=survey,proto3" json:"survey,omitempty"`
+	// Default list of users who can approve this field.
+	// May be adjusted on the issue after creation.
+	Approvers []string `protobuf:"bytes,5,rep,name=approvers,proto3" json:"approvers,omitempty"`
+	// Administrators of this field.
+	Admins []string `protobuf:"bytes,6,rep,name=admins,proto3" json:"admins,omitempty"`
+}
+
+func (x *ApprovalDef) Reset() {
+	*x = ApprovalDef{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ApprovalDef) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ApprovalDef) ProtoMessage() {}
+
+func (x *ApprovalDef) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ApprovalDef.ProtoReflect.Descriptor instead.
+func (*ApprovalDef) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ApprovalDef) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ApprovalDef) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *ApprovalDef) GetDocstring() string {
+	if x != nil {
+		return x.Docstring
+	}
+	return ""
+}
+
+func (x *ApprovalDef) GetSurvey() string {
+	if x != nil {
+		return x.Survey
+	}
+	return ""
+}
+
+func (x *ApprovalDef) GetApprovers() []string {
+	if x != nil {
+		return x.Approvers
+	}
+	return nil
+}
+
+func (x *ApprovalDef) GetAdmins() []string {
+	if x != nil {
+		return x.Admins
+	}
+	return nil
+}
+
+// Defines saved queries that belong to a project
+//
+// Next available tag: 4
+type ProjectSavedQuery struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of this saved query.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Display name of this saved query, ie 'open issues'.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Search term of this saved query.
+	Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"`
+}
+
+func (x *ProjectSavedQuery) Reset() {
+	*x = ProjectSavedQuery{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectSavedQuery) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectSavedQuery) ProtoMessage() {}
+
+func (x *ProjectSavedQuery) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectSavedQuery.ProtoReflect.Descriptor instead.
+func (*ProjectSavedQuery) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ProjectSavedQuery) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ProjectSavedQuery) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *ProjectSavedQuery) GetQuery() string {
+	if x != nil {
+		return x.Query
+	}
+	return ""
+}
+
+// Defines a template for filling issues.
+// Next available tag: 10
+type IssueTemplate struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the template.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Display name of this template.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Canonical Issue for this template.
+	Issue *Issue `protobuf:"bytes,3,opt,name=issue,proto3" json:"issue,omitempty"`
+	// ApprovalValues to be created with the issue when using this template.
+	ApprovalValues []*ApprovalValue `protobuf:"bytes,9,rep,name=approval_values,json=approvalValues,proto3" json:"approval_values,omitempty"`
+	// Boolean indicating subsequent issue creation must have delta in summary.
+	SummaryMustBeEdited bool                          `protobuf:"varint,4,opt,name=summary_must_be_edited,json=summaryMustBeEdited,proto3" json:"summary_must_be_edited,omitempty"`
+	TemplatePrivacy     IssueTemplate_TemplatePrivacy `protobuf:"varint,5,opt,name=template_privacy,json=templatePrivacy,proto3,enum=monorail.v3.IssueTemplate_TemplatePrivacy" json:"template_privacy,omitempty"`
+	DefaultOwner        IssueTemplate_DefaultOwner    `protobuf:"varint,6,opt,name=default_owner,json=defaultOwner,proto3,enum=monorail.v3.IssueTemplate_DefaultOwner" json:"default_owner,omitempty"`
+	// Boolean indicating whether issue must have a component.
+	ComponentRequired bool `protobuf:"varint,7,opt,name=component_required,json=componentRequired,proto3" json:"component_required,omitempty"`
+	// Names of Users who can administer this template.
+	Admins []string `protobuf:"bytes,8,rep,name=admins,proto3" json:"admins,omitempty"`
+}
+
+func (x *IssueTemplate) Reset() {
+	*x = IssueTemplate{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IssueTemplate) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IssueTemplate) ProtoMessage() {}
+
+func (x *IssueTemplate) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IssueTemplate.ProtoReflect.Descriptor instead.
+func (*IssueTemplate) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *IssueTemplate) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *IssueTemplate) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *IssueTemplate) GetIssue() *Issue {
+	if x != nil {
+		return x.Issue
+	}
+	return nil
+}
+
+func (x *IssueTemplate) GetApprovalValues() []*ApprovalValue {
+	if x != nil {
+		return x.ApprovalValues
+	}
+	return nil
+}
+
+func (x *IssueTemplate) GetSummaryMustBeEdited() bool {
+	if x != nil {
+		return x.SummaryMustBeEdited
+	}
+	return false
+}
+
+func (x *IssueTemplate) GetTemplatePrivacy() IssueTemplate_TemplatePrivacy {
+	if x != nil {
+		return x.TemplatePrivacy
+	}
+	return IssueTemplate_TEMPLATE_PRIVACY_UNSPECIFIED
+}
+
+func (x *IssueTemplate) GetDefaultOwner() IssueTemplate_DefaultOwner {
+	if x != nil {
+		return x.DefaultOwner
+	}
+	return IssueTemplate_DEFAULT_OWNER_UNSPECIFIED
+}
+
+func (x *IssueTemplate) GetComponentRequired() bool {
+	if x != nil {
+		return x.ComponentRequired
+	}
+	return false
+}
+
+func (x *IssueTemplate) GetAdmins() []string {
+	if x != nil {
+		return x.Admins
+	}
+	return nil
+}
+
+// Defines configurations of a project
+//
+// Next available tag: 11
+type ProjectConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the project config.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Set of label prefixes that only apply once per issue.
+	// E.g. priority, since no issue can be both Priority-High and Priority-Low.
+	ExclusiveLabelPrefixes []string `protobuf:"bytes,2,rep,name=exclusive_label_prefixes,json=exclusiveLabelPrefixes,proto3" json:"exclusive_label_prefixes,omitempty"`
+	// Default search query for this project's members.
+	MemberDefaultQuery string `protobuf:"bytes,3,opt,name=member_default_query,json=memberDefaultQuery,proto3" json:"member_default_query,omitempty"`
+	// TODO(crbug.com/monorail/7517): consider using IssuesListColumn
+	// Default sort specification for this project.
+	DefaultSort string `protobuf:"bytes,4,opt,name=default_sort,json=defaultSort,proto3" json:"default_sort,omitempty"`
+	// Default columns for displaying issue list for this project.
+	DefaultColumns    []*IssuesListColumn           `protobuf:"bytes,5,rep,name=default_columns,json=defaultColumns,proto3" json:"default_columns,omitempty"`
+	ProjectGridConfig *ProjectConfig_GridViewConfig `protobuf:"bytes,6,opt,name=project_grid_config,json=projectGridConfig,proto3" json:"project_grid_config,omitempty"`
+	// Default template used for issue entry for members of this project.
+	MemberDefaultTemplate string `protobuf:"bytes,7,opt,name=member_default_template,json=memberDefaultTemplate,proto3" json:"member_default_template,omitempty"`
+	// Default template used for issue entry for non-members of this project.
+	NonMembersDefaultTemplate string `protobuf:"bytes,8,opt,name=non_members_default_template,json=nonMembersDefaultTemplate,proto3" json:"non_members_default_template,omitempty"`
+	// URL to browse project's source code revisions for any given revnum.
+	// E.g. https://crrev.com/{revnum}
+	RevisionUrlFormat string `protobuf:"bytes,9,opt,name=revision_url_format,json=revisionUrlFormat,proto3" json:"revision_url_format,omitempty"`
+	// A project's custom URL for the "New issue" link, only if specified.
+	CustomIssueEntryUrl string `protobuf:"bytes,10,opt,name=custom_issue_entry_url,json=customIssueEntryUrl,proto3" json:"custom_issue_entry_url,omitempty"`
+}
+
+func (x *ProjectConfig) Reset() {
+	*x = ProjectConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectConfig) ProtoMessage() {}
+
+func (x *ProjectConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectConfig.ProtoReflect.Descriptor instead.
+func (*ProjectConfig) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *ProjectConfig) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetExclusiveLabelPrefixes() []string {
+	if x != nil {
+		return x.ExclusiveLabelPrefixes
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetMemberDefaultQuery() string {
+	if x != nil {
+		return x.MemberDefaultQuery
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetDefaultSort() string {
+	if x != nil {
+		return x.DefaultSort
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetDefaultColumns() []*IssuesListColumn {
+	if x != nil {
+		return x.DefaultColumns
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetProjectGridConfig() *ProjectConfig_GridViewConfig {
+	if x != nil {
+		return x.ProjectGridConfig
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetMemberDefaultTemplate() string {
+	if x != nil {
+		return x.MemberDefaultTemplate
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetNonMembersDefaultTemplate() string {
+	if x != nil {
+		return x.NonMembersDefaultTemplate
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetRevisionUrlFormat() string {
+	if x != nil {
+		return x.RevisionUrlFormat
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetCustomIssueEntryUrl() string {
+	if x != nil {
+		return x.CustomIssueEntryUrl
+	}
+	return ""
+}
+
+// Specifies info for a member of a project.
+//
+// Next available tag: 7
+type ProjectMember struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the Project Member.
+	// projects/{project}/members/{user_id}
+	Name string                    `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	Role ProjectMember_ProjectRole `protobuf:"varint,2,opt,name=role,proto3,enum=monorail.v3.ProjectMember_ProjectRole" json:"role,omitempty"`
+	// Which built-in/standard permissions the user has set.
+	StandardPerms []Permission `protobuf:"varint,3,rep,packed,name=standard_perms,json=standardPerms,proto3,enum=monorail.v3.Permission" json:"standard_perms,omitempty"`
+	// Custom permissions defined for the user.
+	// eg. "Google" in "Restrict-View-Google" is an example custom permission.
+	CustomPerms []string `protobuf:"bytes,4,rep,name=custom_perms,json=customPerms,proto3" json:"custom_perms,omitempty"`
+	// Annotations about a user configured by project owners.
+	// Visible to anyone who can see the project's settings.
+	Notes                 string                               `protobuf:"bytes,5,opt,name=notes,proto3" json:"notes,omitempty"`
+	IncludeInAutocomplete ProjectMember_AutocompleteVisibility `protobuf:"varint,6,opt,name=include_in_autocomplete,json=includeInAutocomplete,proto3,enum=monorail.v3.ProjectMember_AutocompleteVisibility" json:"include_in_autocomplete,omitempty"`
+}
+
+func (x *ProjectMember) Reset() {
+	*x = ProjectMember{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectMember) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectMember) ProtoMessage() {}
+
+func (x *ProjectMember) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectMember.ProtoReflect.Descriptor instead.
+func (*ProjectMember) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *ProjectMember) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ProjectMember) GetRole() ProjectMember_ProjectRole {
+	if x != nil {
+		return x.Role
+	}
+	return ProjectMember_PROJECT_ROLE_UNSPECIFIED
+}
+
+func (x *ProjectMember) GetStandardPerms() []Permission {
+	if x != nil {
+		return x.StandardPerms
+	}
+	return nil
+}
+
+func (x *ProjectMember) GetCustomPerms() []string {
+	if x != nil {
+		return x.CustomPerms
+	}
+	return nil
+}
+
+func (x *ProjectMember) GetNotes() string {
+	if x != nil {
+		return x.Notes
+	}
+	return ""
+}
+
+func (x *ProjectMember) GetIncludeInAutocomplete() ProjectMember_AutocompleteVisibility {
+	if x != nil {
+		return x.IncludeInAutocomplete
+	}
+	return ProjectMember_AUTOCOMPLETE_VISIBILITY_UNSPECIFIED
+}
+
+// Settings specific to enum type fields.
+// Next available tag: 2
+type FieldDef_EnumTypeSettings struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Choices []*FieldDef_EnumTypeSettings_Choice `protobuf:"bytes,1,rep,name=choices,proto3" json:"choices,omitempty"`
+}
+
+func (x *FieldDef_EnumTypeSettings) Reset() {
+	*x = FieldDef_EnumTypeSettings{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[10]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef_EnumTypeSettings) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef_EnumTypeSettings) ProtoMessage() {}
+
+func (x *FieldDef_EnumTypeSettings) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[10]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef_EnumTypeSettings.ProtoReflect.Descriptor instead.
+func (*FieldDef_EnumTypeSettings) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 0}
+}
+
+func (x *FieldDef_EnumTypeSettings) GetChoices() []*FieldDef_EnumTypeSettings_Choice {
+	if x != nil {
+		return x.Choices
+	}
+	return nil
+}
+
+// Settings specific to int type fields.
+// Next available tag: 3
+type FieldDef_IntTypeSettings struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Minimum value that this field can have.
+	MinValue int32 `protobuf:"varint,1,opt,name=min_value,json=minValue,proto3" json:"min_value,omitempty"`
+	// Maximum value that this field can have.
+	MaxValue int32 `protobuf:"varint,2,opt,name=max_value,json=maxValue,proto3" json:"max_value,omitempty"`
+}
+
+func (x *FieldDef_IntTypeSettings) Reset() {
+	*x = FieldDef_IntTypeSettings{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[11]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef_IntTypeSettings) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef_IntTypeSettings) ProtoMessage() {}
+
+func (x *FieldDef_IntTypeSettings) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[11]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef_IntTypeSettings.ProtoReflect.Descriptor instead.
+func (*FieldDef_IntTypeSettings) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 1}
+}
+
+func (x *FieldDef_IntTypeSettings) GetMinValue() int32 {
+	if x != nil {
+		return x.MinValue
+	}
+	return 0
+}
+
+func (x *FieldDef_IntTypeSettings) GetMaxValue() int32 {
+	if x != nil {
+		return x.MaxValue
+	}
+	return 0
+}
+
+// Settings specific to str type fields.
+// Next available tag: 2
+type FieldDef_StrTypeSettings struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Regex that this field value(s) must match.
+	Regex string `protobuf:"bytes,1,opt,name=regex,proto3" json:"regex,omitempty"`
+}
+
+func (x *FieldDef_StrTypeSettings) Reset() {
+	*x = FieldDef_StrTypeSettings{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[12]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef_StrTypeSettings) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef_StrTypeSettings) ProtoMessage() {}
+
+func (x *FieldDef_StrTypeSettings) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[12]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef_StrTypeSettings.ProtoReflect.Descriptor instead.
+func (*FieldDef_StrTypeSettings) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 2}
+}
+
+func (x *FieldDef_StrTypeSettings) GetRegex() string {
+	if x != nil {
+		return x.Regex
+	}
+	return ""
+}
+
+// Settings specific to user type fields.
+// Next available tag: 5
+type FieldDef_UserTypeSettings struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	NotifyTriggers   FieldDef_UserTypeSettings_NotifyTriggers   `protobuf:"varint,1,opt,name=notify_triggers,json=notifyTriggers,proto3,enum=monorail.v3.FieldDef_UserTypeSettings_NotifyTriggers" json:"notify_triggers,omitempty"`
+	RoleRequirements FieldDef_UserTypeSettings_RoleRequirements `protobuf:"varint,2,opt,name=role_requirements,json=roleRequirements,proto3,enum=monorail.v3.FieldDef_UserTypeSettings_RoleRequirements" json:"role_requirements,omitempty"`
+	// User(s) named in this field are granted this permission in the issue.
+	GrantsPerm string `protobuf:"bytes,3,opt,name=grants_perm,json=grantsPerm,proto3" json:"grants_perm,omitempty"`
+	// Field value(s) can only be set to users with this permission.
+	NeedsPerm string `protobuf:"bytes,4,opt,name=needs_perm,json=needsPerm,proto3" json:"needs_perm,omitempty"`
+}
+
+func (x *FieldDef_UserTypeSettings) Reset() {
+	*x = FieldDef_UserTypeSettings{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[13]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef_UserTypeSettings) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef_UserTypeSettings) ProtoMessage() {}
+
+func (x *FieldDef_UserTypeSettings) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[13]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef_UserTypeSettings.ProtoReflect.Descriptor instead.
+func (*FieldDef_UserTypeSettings) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 3}
+}
+
+func (x *FieldDef_UserTypeSettings) GetNotifyTriggers() FieldDef_UserTypeSettings_NotifyTriggers {
+	if x != nil {
+		return x.NotifyTriggers
+	}
+	return FieldDef_UserTypeSettings_NOTIFY_TRIGGERS_UNSPECIFIED
+}
+
+func (x *FieldDef_UserTypeSettings) GetRoleRequirements() FieldDef_UserTypeSettings_RoleRequirements {
+	if x != nil {
+		return x.RoleRequirements
+	}
+	return FieldDef_UserTypeSettings_ROLE_REQUIREMENTS_UNSPECIFIED
+}
+
+func (x *FieldDef_UserTypeSettings) GetGrantsPerm() string {
+	if x != nil {
+		return x.GrantsPerm
+	}
+	return ""
+}
+
+func (x *FieldDef_UserTypeSettings) GetNeedsPerm() string {
+	if x != nil {
+		return x.NeedsPerm
+	}
+	return ""
+}
+
+// Settings specific to date type fields.
+// Next available tag: 2
+type FieldDef_DateTypeSettings struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	DateAction FieldDef_DateTypeSettings_DateAction `protobuf:"varint,1,opt,name=date_action,json=dateAction,proto3,enum=monorail.v3.FieldDef_DateTypeSettings_DateAction" json:"date_action,omitempty"`
+}
+
+func (x *FieldDef_DateTypeSettings) Reset() {
+	*x = FieldDef_DateTypeSettings{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[14]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef_DateTypeSettings) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef_DateTypeSettings) ProtoMessage() {}
+
+func (x *FieldDef_DateTypeSettings) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[14]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef_DateTypeSettings.ProtoReflect.Descriptor instead.
+func (*FieldDef_DateTypeSettings) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 4}
+}
+
+func (x *FieldDef_DateTypeSettings) GetDateAction() FieldDef_DateTypeSettings_DateAction {
+	if x != nil {
+		return x.DateAction
+	}
+	return FieldDef_DateTypeSettings_DATE_ACTION_UNSPECIFIED
+}
+
+// One available choice for an enum field.
+// Next available tag: 3
+type FieldDef_EnumTypeSettings_Choice struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Value of this choice.
+	Value string `protobuf:"bytes,1,opt,name=value,proto3" json:"value,omitempty"`
+	// Brief explanation of this choice.
+	Docstring string `protobuf:"bytes,2,opt,name=docstring,proto3" json:"docstring,omitempty"`
+}
+
+func (x *FieldDef_EnumTypeSettings_Choice) Reset() {
+	*x = FieldDef_EnumTypeSettings_Choice{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[15]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FieldDef_EnumTypeSettings_Choice) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FieldDef_EnumTypeSettings_Choice) ProtoMessage() {}
+
+func (x *FieldDef_EnumTypeSettings_Choice) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[15]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FieldDef_EnumTypeSettings_Choice.ProtoReflect.Descriptor instead.
+func (*FieldDef_EnumTypeSettings_Choice) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{3, 0, 0}
+}
+
+func (x *FieldDef_EnumTypeSettings_Choice) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+func (x *FieldDef_EnumTypeSettings_Choice) GetDocstring() string {
+	if x != nil {
+		return x.Docstring
+	}
+	return ""
+}
+
+// Grid view configurations.
+// Next available tag: 3
+type ProjectConfig_GridViewConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Default column dimension in grid view for this project.
+	DefaultXAttr string `protobuf:"bytes,1,opt,name=default_x_attr,json=defaultXAttr,proto3" json:"default_x_attr,omitempty"`
+	// Default row dimension in grid view for this project.
+	DefaultYAttr string `protobuf:"bytes,2,opt,name=default_y_attr,json=defaultYAttr,proto3" json:"default_y_attr,omitempty"`
+}
+
+func (x *ProjectConfig_GridViewConfig) Reset() {
+	*x = ProjectConfig_GridViewConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[16]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectConfig_GridViewConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectConfig_GridViewConfig) ProtoMessage() {}
+
+func (x *ProjectConfig_GridViewConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[16]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectConfig_GridViewConfig.ProtoReflect.Descriptor instead.
+func (*ProjectConfig_GridViewConfig) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP(), []int{8, 0}
+}
+
+func (x *ProjectConfig_GridViewConfig) GetDefaultXAttr() string {
+	if x != nil {
+		return x.DefaultXAttr
+	}
+	return ""
+}
+
+func (x *ProjectConfig_GridViewConfig) GetDefaultYAttr() string {
+	if x != nil {
+		return x.DefaultYAttr
+	}
+	return ""
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDesc = []byte{
+	0x0a, 0x54, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69,
+	0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70,
+	0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x1a, 0x52, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x57, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75,
+	0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79,
+	0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67,
+	0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
+	0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xb4, 0x01,
+	0x0a, 0x07, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a,
+	0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x05, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61,
+	0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12,
+	0x23, 0x0a, 0x0d, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69, 0x6c, 0x5f, 0x75, 0x72, 0x6c,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x68, 0x75, 0x6d, 0x62, 0x6e, 0x61, 0x69,
+	0x6c, 0x55, 0x72, 0x6c, 0x3a, 0x2e, 0xea, 0x41, 0x2b, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63,
+	0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x12, 0x12, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x7d, 0x22, 0xcc, 0x03, 0x0a, 0x09, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44,
+	0x65, 0x66, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x38, 0x0a, 0x04,
+	0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x24, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44,
+	0x65, 0x66, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44, 0x65, 0x66, 0x54, 0x79, 0x70, 0x65,
+	0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x61, 0x6e, 0x6b, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x72, 0x61, 0x6e, 0x6b, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6f,
+	0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64,
+	0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x12, 0x3b, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74,
+	0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x25, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44, 0x65, 0x66, 0x2e,
+	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44, 0x65, 0x66, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05,
+	0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0x52, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x44,
+	0x65, 0x66, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1f, 0x0a, 0x1b, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53,
+	0x5f, 0x44, 0x45, 0x46, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43,
+	0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x4f, 0x50, 0x45, 0x4e, 0x10,
+	0x01, 0x12, 0x0a, 0x0a, 0x06, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0a, 0x0a,
+	0x06, 0x4d, 0x45, 0x52, 0x47, 0x45, 0x44, 0x10, 0x03, 0x22, 0x4e, 0x0a, 0x0e, 0x53, 0x74, 0x61,
+	0x74, 0x75, 0x73, 0x44, 0x65, 0x66, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x1c, 0x53,
+	0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x44, 0x45, 0x46, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f,
+	0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0e, 0x0a,
+	0x0a, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a,
+	0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x02, 0x3a, 0x48, 0xea, 0x41, 0x45, 0x0a, 0x17,
+	0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x53, 0x74,
+	0x61, 0x74, 0x75, 0x73, 0x44, 0x65, 0x66, 0x12, 0x2a, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x44, 0x65, 0x66, 0x73, 0x2f, 0x7b, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x64,
+	0x65, 0x66, 0x7d, 0x22, 0xa2, 0x02, 0x0a, 0x08, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x44, 0x65, 0x66,
+	0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6f,
+	0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64,
+	0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x12, 0x39, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74,
+	0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x44, 0x65, 0x66, 0x2e, 0x4c,
+	0x61, 0x62, 0x65, 0x6c, 0x44, 0x65, 0x66, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74,
+	0x61, 0x74, 0x65, 0x22, 0x4c, 0x0a, 0x0d, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x44, 0x65, 0x66, 0x53,
+	0x74, 0x61, 0x74, 0x65, 0x12, 0x1f, 0x0a, 0x1b, 0x4c, 0x41, 0x42, 0x45, 0x4c, 0x5f, 0x44, 0x45,
+	0x46, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46,
+	0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, 0x41,
+	0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10,
+	0x02, 0x3a, 0x45, 0xea, 0x41, 0x42, 0x0a, 0x16, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x44, 0x65, 0x66, 0x12, 0x28,
+	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x7d, 0x2f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x44, 0x65, 0x66, 0x73, 0x2f, 0x7b, 0x6c, 0x61,
+	0x62, 0x65, 0x6c, 0x5f, 0x64, 0x65, 0x66, 0x7d, 0x22, 0xed, 0x0f, 0x0a, 0x08, 0x46, 0x69, 0x65,
+	0x6c, 0x64, 0x44, 0x65, 0x66, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69, 0x73,
+	0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x03, 0xe0, 0x41, 0x05, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d,
+	0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x12,
+	0x33, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1a, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c,
+	0x64, 0x44, 0x65, 0x66, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x05, 0x52, 0x04,
+	0x74, 0x79, 0x70, 0x65, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x62,
+	0x6c, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x05, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x13, 0x61, 0x70, 0x70, 0x6c, 0x69, 0x63, 0x61, 0x62, 0x6c, 0x65, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x61, 0x64, 0x6d, 0x69,
+	0x6e, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61,
+	0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65,
+	0x72, 0x52, 0x06, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x73, 0x12, 0x34, 0x0a, 0x06, 0x74, 0x72, 0x61,
+	0x69, 0x74, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66,
+	0x2e, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x52, 0x06, 0x74, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12,
+	0x4a, 0x0a, 0x0f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x5f, 0x70, 0x61, 0x72, 0x65,
+	0x6e, 0x74, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x21, 0xfa, 0x41, 0x1b, 0x0a, 0x19, 0x61,
+	0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x41, 0x70, 0x70,
+	0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x66, 0xe0, 0x41, 0x05, 0x52, 0x0e, 0x61, 0x70, 0x70,
+	0x72, 0x6f, 0x76, 0x61, 0x6c, 0x50, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0d, 0x65,
+	0x6e, 0x75, 0x6d, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x09, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x54, 0x79,
+	0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x65, 0x6e, 0x75, 0x6d,
+	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x48, 0x0a, 0x0c, 0x69, 0x6e, 0x74, 0x5f,
+	0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65,
+	0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e, 0x49, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74,
+	0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0b, 0x69, 0x6e, 0x74, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e,
+	0x67, 0x73, 0x12, 0x48, 0x0a, 0x0c, 0x73, 0x74, 0x72, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e,
+	0x67, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e,
+	0x53, 0x74, 0x72, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52,
+	0x0b, 0x73, 0x74, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x4b, 0x0a, 0x0d,
+	0x75, 0x73, 0x65, 0x72, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x0c, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x54,
+	0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x75, 0x73, 0x65,
+	0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x4b, 0x0a, 0x0d, 0x64, 0x61, 0x74,
+	0x65, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46,
+	0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65,
+	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65,
+	0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x31, 0x0a, 0x07, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72,
+	0x73, 0x18, 0x0e, 0x20, 0x03, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70,
+	0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72,
+	0x52, 0x07, 0x65, 0x64, 0x69, 0x74, 0x6f, 0x72, 0x73, 0x1a, 0x99, 0x01, 0x0a, 0x10, 0x45, 0x6e,
+	0x75, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x47,
+	0x0a, 0x07, 0x63, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x2d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69,
+	0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e, 0x45, 0x6e, 0x75, 0x6d, 0x54, 0x79, 0x70, 0x65, 0x53,
+	0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x43, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x07,
+	0x63, 0x68, 0x6f, 0x69, 0x63, 0x65, 0x73, 0x1a, 0x3c, 0x0a, 0x06, 0x43, 0x68, 0x6f, 0x69, 0x63,
+	0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74,
+	0x72, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6f, 0x63, 0x73,
+	0x74, 0x72, 0x69, 0x6e, 0x67, 0x1a, 0x4b, 0x0a, 0x0f, 0x49, 0x6e, 0x74, 0x54, 0x79, 0x70, 0x65,
+	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x69, 0x6e, 0x5f,
+	0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x6d, 0x69, 0x6e,
+	0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x6d, 0x61, 0x78, 0x5f, 0x76, 0x61, 0x6c,
+	0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x6d, 0x61, 0x78, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x1a, 0x27, 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74,
+	0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x67, 0x65, 0x78, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x67, 0x65, 0x78, 0x1a, 0xcb, 0x03, 0x0a, 0x10,
+	0x55, 0x73, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73,
+	0x12, 0x5e, 0x0a, 0x0f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x72, 0x69, 0x67, 0x67,
+	0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x35, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66,
+	0x2e, 0x55, 0x73, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67,
+	0x73, 0x2e, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x73,
+	0x52, 0x0e, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x79, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x73,
+	0x12, 0x64, 0x0a, 0x11, 0x72, 0x6f, 0x6c, 0x65, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65,
+	0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x37, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44,
+	0x65, 0x66, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69,
+	0x6e, 0x67, 0x73, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d,
+	0x65, 0x6e, 0x74, 0x73, 0x52, 0x10, 0x72, 0x6f, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72,
+	0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x67, 0x72, 0x61, 0x6e, 0x74, 0x73,
+	0x5f, 0x70, 0x65, 0x72, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x67, 0x72, 0x61,
+	0x6e, 0x74, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x12, 0x1d, 0x0a, 0x0a, 0x6e, 0x65, 0x65, 0x64, 0x73,
+	0x5f, 0x70, 0x65, 0x72, 0x6d, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6e, 0x65, 0x65,
+	0x64, 0x73, 0x50, 0x65, 0x72, 0x6d, 0x22, 0x4d, 0x0a, 0x0e, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x79,
+	0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x73, 0x12, 0x1f, 0x0a, 0x1b, 0x4e, 0x4f, 0x54, 0x49,
+	0x46, 0x59, 0x5f, 0x54, 0x52, 0x49, 0x47, 0x47, 0x45, 0x52, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50,
+	0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x09, 0x0a, 0x05, 0x4e, 0x45, 0x56,
+	0x45, 0x52, 0x10, 0x01, 0x12, 0x0f, 0x0a, 0x0b, 0x41, 0x4e, 0x59, 0x5f, 0x43, 0x4f, 0x4d, 0x4d,
+	0x45, 0x4e, 0x54, 0x10, 0x02, 0x22, 0x62, 0x0a, 0x10, 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x65, 0x71,
+	0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x21, 0x0a, 0x1d, 0x52, 0x4f, 0x4c,
+	0x45, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x49, 0x52, 0x45, 0x4d, 0x45, 0x4e, 0x54, 0x53, 0x5f, 0x55,
+	0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x17, 0x0a, 0x13,
+	0x4e, 0x4f, 0x5f, 0x52, 0x4f, 0x4c, 0x45, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x49, 0x52, 0x45, 0x4d,
+	0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54,
+	0x5f, 0x4d, 0x45, 0x4d, 0x42, 0x45, 0x52, 0x10, 0x02, 0x1a, 0xcb, 0x01, 0x0a, 0x10, 0x44, 0x61,
+	0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x52,
+	0x0a, 0x0b, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x0e, 0x32, 0x31, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x2e, 0x44, 0x61, 0x74, 0x65, 0x54,
+	0x79, 0x70, 0x65, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x44, 0x61, 0x74, 0x65,
+	0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0a, 0x64, 0x61, 0x74, 0x65, 0x41, 0x63, 0x74, 0x69,
+	0x6f, 0x6e, 0x22, 0x63, 0x0a, 0x0a, 0x44, 0x61, 0x74, 0x65, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e,
+	0x12, 0x1b, 0x0a, 0x17, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f,
+	0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0d, 0x0a,
+	0x09, 0x4e, 0x4f, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c,
+	0x4e, 0x4f, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x02, 0x12, 0x17,
+	0x0a, 0x13, 0x4e, 0x4f, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x50, 0x41, 0x52, 0x54, 0x49, 0x43, 0x49,
+	0x50, 0x41, 0x4e, 0x54, 0x53, 0x10, 0x03, 0x22, 0x55, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12,
+	0x14, 0x0a, 0x10, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46,
+	0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x45, 0x4e, 0x55, 0x4d, 0x10, 0x01, 0x12,
+	0x07, 0x0a, 0x03, 0x49, 0x4e, 0x54, 0x10, 0x02, 0x12, 0x07, 0x0a, 0x03, 0x53, 0x54, 0x52, 0x10,
+	0x03, 0x12, 0x08, 0x0a, 0x04, 0x55, 0x53, 0x45, 0x52, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x44,
+	0x41, 0x54, 0x45, 0x10, 0x05, 0x12, 0x07, 0x0a, 0x03, 0x55, 0x52, 0x4c, 0x10, 0x06, 0x22, 0x6e,
+	0x0a, 0x06, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12, 0x16, 0x0a, 0x12, 0x54, 0x52, 0x41, 0x49,
+	0x54, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
+	0x12, 0x0c, 0x0a, 0x08, 0x52, 0x45, 0x51, 0x55, 0x49, 0x52, 0x45, 0x44, 0x10, 0x01, 0x12, 0x12,
+	0x0a, 0x0e, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x5f, 0x48, 0x49, 0x44, 0x44, 0x45, 0x4e,
+	0x10, 0x02, 0x12, 0x0f, 0x0a, 0x0b, 0x4d, 0x55, 0x4c, 0x54, 0x49, 0x56, 0x41, 0x4c, 0x55, 0x45,
+	0x44, 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x48, 0x41, 0x53, 0x45, 0x10, 0x04, 0x12, 0x0e,
+	0x0a, 0x0a, 0x52, 0x45, 0x53, 0x54, 0x52, 0x49, 0x43, 0x54, 0x45, 0x44, 0x10, 0x05, 0x3a, 0x48,
+	0xea, 0x41, 0x45, 0x0a, 0x16, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63,
+	0x6f, 0x6d, 0x2f, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x12, 0x2b, 0x70, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f,
+	0x66, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x73, 0x2f, 0x7b, 0x66, 0x69, 0x65, 0x6c, 0x64,
+	0x5f, 0x64, 0x65, 0x66, 0x5f, 0x69, 0x64, 0x7d, 0x22, 0xab, 0x05, 0x0a, 0x0c, 0x43, 0x6f, 0x6d,
+	0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a,
+	0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61,
+	0x6c, 0x75, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e,
+	0x67, 0x12, 0x2f, 0x0a, 0x06, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28,
+	0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x06, 0x61, 0x64, 0x6d, 0x69,
+	0x6e, 0x73, 0x12, 0x29, 0x0a, 0x03, 0x63, 0x63, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x42,
+	0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x03, 0x63, 0x63, 0x73, 0x12, 0x41, 0x0a,
+	0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+	0x74, 0x44, 0x65, 0x66, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65,
+	0x12, 0x34, 0x0a, 0x07, 0x63, 0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x18, 0x07, 0x20, 0x01, 0x28,
+	0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x03, 0x52, 0x07, 0x63,
+	0x72, 0x65, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x36, 0x0a, 0x08, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69,
+	0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61,
+	0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65,
+	0x72, 0xe0, 0x41, 0x03, 0x52, 0x08, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x40,
+	0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x09, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42,
+	0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65,
+	0x12, 0x40, 0x0a, 0x0b, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
+	0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+	0x70, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x6d, 0x6f, 0x64, 0x69, 0x66, 0x79, 0x54, 0x69,
+	0x6d, 0x65, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x18, 0x0b, 0x20, 0x03,
+	0x28, 0x09, 0x52, 0x06, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x73, 0x22, 0x54, 0x0a, 0x11, 0x43, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12,
+	0x23, 0x0a, 0x1f, 0x43, 0x4f, 0x4d, 0x50, 0x4f, 0x4e, 0x45, 0x4e, 0x54, 0x5f, 0x44, 0x45, 0x46,
+	0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49,
+	0x45, 0x44, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x44, 0x45, 0x50, 0x52, 0x45, 0x43, 0x41, 0x54,
+	0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x02,
+	0x3a, 0x54, 0xea, 0x41, 0x51, 0x0a, 0x1a, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67,
+	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65,
+	0x66, 0x12, 0x33, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44,
+	0x65, 0x66, 0x73, 0x2f, 0x7b, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x5f, 0x64,
+	0x65, 0x66, 0x5f, 0x69, 0x64, 0x7d, 0x22, 0xba, 0x02, 0x0a, 0x0b, 0x41, 0x70, 0x70, 0x72, 0x6f,
+	0x76, 0x61, 0x6c, 0x44, 0x65, 0x66, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69,
+	0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x42, 0x03, 0xe0, 0x41, 0x05, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61,
+	0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x64, 0x6f, 0x63, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67,
+	0x12, 0x16, 0x0a, 0x06, 0x73, 0x75, 0x72, 0x76, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x06, 0x73, 0x75, 0x72, 0x76, 0x65, 0x79, 0x12, 0x35, 0x0a, 0x09, 0x61, 0x70, 0x70, 0x72,
+	0x6f, 0x76, 0x65, 0x72, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14,
+	0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x55, 0x73, 0x65, 0x72, 0x52, 0x09, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x65, 0x72, 0x73, 0x12,
+	0x2f, 0x0a, 0x06, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x42,
+	0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x06, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x73,
+	0x3a, 0x51, 0xea, 0x41, 0x4e, 0x0a, 0x19, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67,
+	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x41, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x66,
+	0x12, 0x31, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x7d, 0x2f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x44, 0x65, 0x66,
+	0x73, 0x2f, 0x7b, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x5f, 0x64, 0x65, 0x66, 0x5f,
+	0x69, 0x64, 0x7d, 0x22, 0xb8, 0x01, 0x0a, 0x11, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53,
+	0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a,
+	0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65,
+	0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x3a, 0x56, 0xea, 0x41, 0x53, 0x0a, 0x1f, 0x61, 0x70, 0x69,
+	0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x53, 0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x30, 0x70, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d,
+	0x2f, 0x73, 0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x2f, 0x7b, 0x73,
+	0x61, 0x76, 0x65, 0x64, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x7d, 0x22, 0xe1,
+	0x05, 0x0a, 0x0d, 0x49, 0x73, 0x73, 0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65,
+	0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x05, 0x52,
+	0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x05,
+	0x69, 0x73, 0x73, 0x75, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x52,
+	0x05, 0x69, 0x73, 0x73, 0x75, 0x65, 0x12, 0x43, 0x0a, 0x0f, 0x61, 0x70, 0x70, 0x72, 0x6f, 0x76,
+	0x61, 0x6c, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x41, 0x70,
+	0x70, 0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x0e, 0x61, 0x70, 0x70,
+	0x72, 0x6f, 0x76, 0x61, 0x6c, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x33, 0x0a, 0x16, 0x73,
+	0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x5f, 0x6d, 0x75, 0x73, 0x74, 0x5f, 0x62, 0x65, 0x5f, 0x65,
+	0x64, 0x69, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x73, 0x75, 0x6d,
+	0x6d, 0x61, 0x72, 0x79, 0x4d, 0x75, 0x73, 0x74, 0x42, 0x65, 0x45, 0x64, 0x69, 0x74, 0x65, 0x64,
+	0x12, 0x55, 0x0a, 0x10, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x70, 0x72, 0x69,
+	0x76, 0x61, 0x63, 0x79, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2a, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x54, 0x65,
+	0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x2e, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x50,
+	0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x52, 0x0f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65,
+	0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x12, 0x4c, 0x0a, 0x0d, 0x64, 0x65, 0x66, 0x61, 0x75,
+	0x6c, 0x74, 0x5f, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x27,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73,
+	0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x2e, 0x44, 0x65, 0x66, 0x61, 0x75,
+	0x6c, 0x74, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x52, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74,
+	0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65,
+	0x6e, 0x74, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x11, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
+	0x69, 0x72, 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x61, 0x64, 0x6d, 0x69, 0x6e, 0x73, 0x18, 0x08,
+	0x20, 0x03, 0x28, 0x09, 0x42, 0x17, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63,
+	0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x06, 0x61,
+	0x64, 0x6d, 0x69, 0x6e, 0x73, 0x22, 0x51, 0x0a, 0x0f, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74,
+	0x65, 0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x12, 0x20, 0x0a, 0x1c, 0x54, 0x45, 0x4d, 0x50,
+	0x4c, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x49, 0x56, 0x41, 0x43, 0x59, 0x5f, 0x55, 0x4e, 0x53,
+	0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x10, 0x0a, 0x0c, 0x4d, 0x45,
+	0x4d, 0x42, 0x45, 0x52, 0x53, 0x5f, 0x4f, 0x4e, 0x4c, 0x59, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06,
+	0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x22, 0x4a, 0x0a, 0x0c, 0x44, 0x65, 0x66, 0x61,
+	0x75, 0x6c, 0x74, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x1d, 0x0a, 0x19, 0x44, 0x45, 0x46, 0x41,
+	0x55, 0x4c, 0x54, 0x5f, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43,
+	0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x50, 0x52, 0x4f, 0x4a, 0x45,
+	0x43, 0x54, 0x5f, 0x4d, 0x45, 0x4d, 0x42, 0x45, 0x52, 0x5f, 0x52, 0x45, 0x50, 0x4f, 0x52, 0x54,
+	0x45, 0x52, 0x10, 0x01, 0x3a, 0x4c, 0xea, 0x41, 0x49, 0x0a, 0x1b, 0x61, 0x70, 0x69, 0x2e, 0x63,
+	0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x49, 0x73, 0x73, 0x75, 0x65, 0x54, 0x65,
+	0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x2a, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73,
+	0x2f, 0x7b, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f, 0x74, 0x65, 0x6d, 0x70, 0x6c,
+	0x61, 0x74, 0x65, 0x73, 0x2f, 0x7b, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x5f, 0x69,
+	0x64, 0x7d, 0x22, 0x88, 0x06, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f,
+	0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x38, 0x0a, 0x18, 0x65, 0x78, 0x63, 0x6c,
+	0x75, 0x73, 0x69, 0x76, 0x65, 0x5f, 0x6c, 0x61, 0x62, 0x65, 0x6c, 0x5f, 0x70, 0x72, 0x65, 0x66,
+	0x69, 0x78, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x16, 0x65, 0x78, 0x63, 0x6c,
+	0x75, 0x73, 0x69, 0x76, 0x65, 0x4c, 0x61, 0x62, 0x65, 0x6c, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78,
+	0x65, 0x73, 0x12, 0x30, 0x0a, 0x14, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x66,
+	0x61, 0x75, 0x6c, 0x74, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x12, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x51,
+	0x75, 0x65, 0x72, 0x79, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f,
+	0x73, 0x6f, 0x72, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x66, 0x61,
+	0x75, 0x6c, 0x74, 0x53, 0x6f, 0x72, 0x74, 0x12, 0x46, 0x0a, 0x0f, 0x64, 0x65, 0x66, 0x61, 0x75,
+	0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x1d, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x73, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52,
+	0x0e, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x73, 0x12,
+	0x59, 0x0a, 0x13, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x67, 0x72, 0x69, 0x64, 0x5f,
+	0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x47, 0x72, 0x69, 0x64, 0x56, 0x69, 0x65,
+	0x77, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x11, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x47, 0x72, 0x69, 0x64, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x53, 0x0a, 0x17, 0x6d, 0x65,
+	0x6d, 0x62, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x74, 0x65, 0x6d,
+	0x70, 0x6c, 0x61, 0x74, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1b, 0xfa, 0x41, 0x18,
+	0x0a, 0x16, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f,
+	0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x52, 0x15, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72,
+	0x44, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12,
+	0x5c, 0x0a, 0x1c, 0x6e, 0x6f, 0x6e, 0x5f, 0x6d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x5f, 0x64,
+	0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x18,
+	0x08, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1b, 0xfa, 0x41, 0x18, 0x0a, 0x16, 0x61, 0x70, 0x69, 0x2e,
+	0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61,
+	0x74, 0x65, 0x52, 0x19, 0x6e, 0x6f, 0x6e, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x73, 0x44, 0x65,
+	0x66, 0x61, 0x75, 0x6c, 0x74, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x2e, 0x0a,
+	0x13, 0x72, 0x65, 0x76, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x5f, 0x66, 0x6f,
+	0x72, 0x6d, 0x61, 0x74, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x72, 0x65, 0x76, 0x69,
+	0x73, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x46, 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x12, 0x33, 0x0a,
+	0x16, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x5f, 0x65, 0x6e,
+	0x74, 0x72, 0x79, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x63,
+	0x75, 0x73, 0x74, 0x6f, 0x6d, 0x49, 0x73, 0x73, 0x75, 0x65, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x55,
+	0x72, 0x6c, 0x1a, 0x5c, 0x0a, 0x0e, 0x47, 0x72, 0x69, 0x64, 0x56, 0x69, 0x65, 0x77, 0x43, 0x6f,
+	0x6e, 0x66, 0x69, 0x67, 0x12, 0x24, 0x0a, 0x0e, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f,
+	0x78, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65,
+	0x66, 0x61, 0x75, 0x6c, 0x74, 0x58, 0x41, 0x74, 0x74, 0x72, 0x12, 0x24, 0x0a, 0x0e, 0x64, 0x65,
+	0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x79, 0x5f, 0x61, 0x74, 0x74, 0x72, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x59, 0x41, 0x74, 0x74, 0x72,
+	0x3a, 0x3b, 0xea, 0x41, 0x38, 0x0a, 0x1b, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67,
+	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x12, 0x19, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2f, 0x7b, 0x70, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x7d, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0xf5, 0x03,
+	0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x12,
+	0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
+	0x61, 0x6d, 0x65, 0x12, 0x3a, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x0e, 0x32, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x6d, 0x62, 0x65, 0x72, 0x2e, 0x50, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x6f, 0x6c, 0x65, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12,
+	0x3e, 0x0a, 0x0e, 0x73, 0x74, 0x61, 0x6e, 0x64, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x65, 0x72, 0x6d,
+	0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x65, 0x72, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e,
+	0x52, 0x0d, 0x73, 0x74, 0x61, 0x6e, 0x64, 0x61, 0x72, 0x64, 0x50, 0x65, 0x72, 0x6d, 0x73, 0x12,
+	0x21, 0x0a, 0x0c, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x5f, 0x70, 0x65, 0x72, 0x6d, 0x73, 0x18,
+	0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x75, 0x73, 0x74, 0x6f, 0x6d, 0x50, 0x65, 0x72,
+	0x6d, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x6f, 0x74, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x05, 0x6e, 0x6f, 0x74, 0x65, 0x73, 0x12, 0x69, 0x0a, 0x17, 0x69, 0x6e, 0x63, 0x6c,
+	0x75, 0x64, 0x65, 0x5f, 0x69, 0x6e, 0x5f, 0x61, 0x75, 0x74, 0x6f, 0x63, 0x6f, 0x6d, 0x70, 0x6c,
+	0x65, 0x74, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x31, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d,
+	0x65, 0x6d, 0x62, 0x65, 0x72, 0x2e, 0x41, 0x75, 0x74, 0x6f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65,
+	0x74, 0x65, 0x56, 0x69, 0x73, 0x69, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x52, 0x15, 0x69, 0x6e,
+	0x63, 0x6c, 0x75, 0x64, 0x65, 0x49, 0x6e, 0x41, 0x75, 0x74, 0x6f, 0x63, 0x6f, 0x6d, 0x70, 0x6c,
+	0x65, 0x74, 0x65, 0x22, 0x56, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x6f,
+	0x6c, 0x65, 0x12, 0x1c, 0x0a, 0x18, 0x50, 0x52, 0x4f, 0x4a, 0x45, 0x43, 0x54, 0x5f, 0x52, 0x4f,
+	0x4c, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
+	0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0d, 0x0a, 0x09, 0x43,
+	0x4f, 0x4d, 0x4d, 0x49, 0x54, 0x54, 0x45, 0x52, 0x10, 0x02, 0x12, 0x0f, 0x0a, 0x0b, 0x43, 0x4f,
+	0x4e, 0x54, 0x52, 0x49, 0x42, 0x55, 0x54, 0x4f, 0x52, 0x10, 0x03, 0x22, 0x58, 0x0a, 0x16, 0x41,
+	0x75, 0x74, 0x6f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x56, 0x69, 0x73, 0x69, 0x62,
+	0x69, 0x6c, 0x69, 0x74, 0x79, 0x12, 0x27, 0x0a, 0x23, 0x41, 0x55, 0x54, 0x4f, 0x43, 0x4f, 0x4d,
+	0x50, 0x4c, 0x45, 0x54, 0x45, 0x5f, 0x56, 0x49, 0x53, 0x49, 0x42, 0x49, 0x4c, 0x49, 0x54, 0x59,
+	0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a,
+	0x0a, 0x06, 0x48, 0x49, 0x44, 0x44, 0x45, 0x4e, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x53, 0x48,
+	0x4f, 0x57, 0x4e, 0x10, 0x02, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f,
+	0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e,
+	0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f,
+	0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70,
+	0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes = make([]protoimpl.EnumInfo, 13)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes = make([]protoimpl.MessageInfo, 17)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_goTypes = []interface{}{
+	(StatusDef_StatusDefType)(0),                    // 0: monorail.v3.StatusDef.StatusDefType
+	(StatusDef_StatusDefState)(0),                   // 1: monorail.v3.StatusDef.StatusDefState
+	(LabelDef_LabelDefState)(0),                     // 2: monorail.v3.LabelDef.LabelDefState
+	(FieldDef_Type)(0),                              // 3: monorail.v3.FieldDef.Type
+	(FieldDef_Traits)(0),                            // 4: monorail.v3.FieldDef.Traits
+	(FieldDef_UserTypeSettings_NotifyTriggers)(0),   // 5: monorail.v3.FieldDef.UserTypeSettings.NotifyTriggers
+	(FieldDef_UserTypeSettings_RoleRequirements)(0), // 6: monorail.v3.FieldDef.UserTypeSettings.RoleRequirements
+	(FieldDef_DateTypeSettings_DateAction)(0),       // 7: monorail.v3.FieldDef.DateTypeSettings.DateAction
+	(ComponentDef_ComponentDefState)(0),             // 8: monorail.v3.ComponentDef.ComponentDefState
+	(IssueTemplate_TemplatePrivacy)(0),              // 9: monorail.v3.IssueTemplate.TemplatePrivacy
+	(IssueTemplate_DefaultOwner)(0),                 // 10: monorail.v3.IssueTemplate.DefaultOwner
+	(ProjectMember_ProjectRole)(0),                  // 11: monorail.v3.ProjectMember.ProjectRole
+	(ProjectMember_AutocompleteVisibility)(0),       // 12: monorail.v3.ProjectMember.AutocompleteVisibility
+	(*Project)(nil),                                 // 13: monorail.v3.Project
+	(*StatusDef)(nil),                               // 14: monorail.v3.StatusDef
+	(*LabelDef)(nil),                                // 15: monorail.v3.LabelDef
+	(*FieldDef)(nil),                                // 16: monorail.v3.FieldDef
+	(*ComponentDef)(nil),                            // 17: monorail.v3.ComponentDef
+	(*ApprovalDef)(nil),                             // 18: monorail.v3.ApprovalDef
+	(*ProjectSavedQuery)(nil),                       // 19: monorail.v3.ProjectSavedQuery
+	(*IssueTemplate)(nil),                           // 20: monorail.v3.IssueTemplate
+	(*ProjectConfig)(nil),                           // 21: monorail.v3.ProjectConfig
+	(*ProjectMember)(nil),                           // 22: monorail.v3.ProjectMember
+	(*FieldDef_EnumTypeSettings)(nil),               // 23: monorail.v3.FieldDef.EnumTypeSettings
+	(*FieldDef_IntTypeSettings)(nil),                // 24: monorail.v3.FieldDef.IntTypeSettings
+	(*FieldDef_StrTypeSettings)(nil),                // 25: monorail.v3.FieldDef.StrTypeSettings
+	(*FieldDef_UserTypeSettings)(nil),               // 26: monorail.v3.FieldDef.UserTypeSettings
+	(*FieldDef_DateTypeSettings)(nil),               // 27: monorail.v3.FieldDef.DateTypeSettings
+	(*FieldDef_EnumTypeSettings_Choice)(nil),        // 28: monorail.v3.FieldDef.EnumTypeSettings.Choice
+	(*ProjectConfig_GridViewConfig)(nil),            // 29: monorail.v3.ProjectConfig.GridViewConfig
+	(*timestamppb.Timestamp)(nil),                   // 30: google.protobuf.Timestamp
+	(*Issue)(nil),                                   // 31: monorail.v3.Issue
+	(*ApprovalValue)(nil),                           // 32: monorail.v3.ApprovalValue
+	(*IssuesListColumn)(nil),                        // 33: monorail.v3.IssuesListColumn
+	(Permission)(0),                                 // 34: monorail.v3.Permission
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_depIdxs = []int32{
+	0,  // 0: monorail.v3.StatusDef.type:type_name -> monorail.v3.StatusDef.StatusDefType
+	1,  // 1: monorail.v3.StatusDef.state:type_name -> monorail.v3.StatusDef.StatusDefState
+	2,  // 2: monorail.v3.LabelDef.state:type_name -> monorail.v3.LabelDef.LabelDefState
+	3,  // 3: monorail.v3.FieldDef.type:type_name -> monorail.v3.FieldDef.Type
+	4,  // 4: monorail.v3.FieldDef.traits:type_name -> monorail.v3.FieldDef.Traits
+	23, // 5: monorail.v3.FieldDef.enum_settings:type_name -> monorail.v3.FieldDef.EnumTypeSettings
+	24, // 6: monorail.v3.FieldDef.int_settings:type_name -> monorail.v3.FieldDef.IntTypeSettings
+	25, // 7: monorail.v3.FieldDef.str_settings:type_name -> monorail.v3.FieldDef.StrTypeSettings
+	26, // 8: monorail.v3.FieldDef.user_settings:type_name -> monorail.v3.FieldDef.UserTypeSettings
+	27, // 9: monorail.v3.FieldDef.date_settings:type_name -> monorail.v3.FieldDef.DateTypeSettings
+	8,  // 10: monorail.v3.ComponentDef.state:type_name -> monorail.v3.ComponentDef.ComponentDefState
+	30, // 11: monorail.v3.ComponentDef.create_time:type_name -> google.protobuf.Timestamp
+	30, // 12: monorail.v3.ComponentDef.modify_time:type_name -> google.protobuf.Timestamp
+	31, // 13: monorail.v3.IssueTemplate.issue:type_name -> monorail.v3.Issue
+	32, // 14: monorail.v3.IssueTemplate.approval_values:type_name -> monorail.v3.ApprovalValue
+	9,  // 15: monorail.v3.IssueTemplate.template_privacy:type_name -> monorail.v3.IssueTemplate.TemplatePrivacy
+	10, // 16: monorail.v3.IssueTemplate.default_owner:type_name -> monorail.v3.IssueTemplate.DefaultOwner
+	33, // 17: monorail.v3.ProjectConfig.default_columns:type_name -> monorail.v3.IssuesListColumn
+	29, // 18: monorail.v3.ProjectConfig.project_grid_config:type_name -> monorail.v3.ProjectConfig.GridViewConfig
+	11, // 19: monorail.v3.ProjectMember.role:type_name -> monorail.v3.ProjectMember.ProjectRole
+	34, // 20: monorail.v3.ProjectMember.standard_perms:type_name -> monorail.v3.Permission
+	12, // 21: monorail.v3.ProjectMember.include_in_autocomplete:type_name -> monorail.v3.ProjectMember.AutocompleteVisibility
+	28, // 22: monorail.v3.FieldDef.EnumTypeSettings.choices:type_name -> monorail.v3.FieldDef.EnumTypeSettings.Choice
+	5,  // 23: monorail.v3.FieldDef.UserTypeSettings.notify_triggers:type_name -> monorail.v3.FieldDef.UserTypeSettings.NotifyTriggers
+	6,  // 24: monorail.v3.FieldDef.UserTypeSettings.role_requirements:type_name -> monorail.v3.FieldDef.UserTypeSettings.RoleRequirements
+	7,  // 25: monorail.v3.FieldDef.DateTypeSettings.date_action:type_name -> monorail.v3.FieldDef.DateTypeSettings.DateAction
+	26, // [26:26] is the sub-list for method output_type
+	26, // [26:26] is the sub-list for method input_type
+	26, // [26:26] is the sub-list for extension type_name
+	26, // [26:26] is the sub-list for extension extendee
+	0,  // [0:26] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_issue_objects_proto_init()
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_permission_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Project); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*StatusDef); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*LabelDef); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ComponentDef); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ApprovalDef); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectSavedQuery); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IssueTemplate); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectMember); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef_EnumTypeSettings); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef_IntTypeSettings); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef_StrTypeSettings); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef_UserTypeSettings); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef_DateTypeSettings); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FieldDef_EnumTypeSettings_Choice); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectConfig_GridViewConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDesc,
+			NumEnums:      13,
+			NumMessages:   17,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_depIdxs,
+		EnumInfos:         file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_enumTypes,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_depIdxs = nil
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/project_objects.proto b/analysis/internal/bugs/monorail/api_proto/project_objects.proto
new file mode 100644
index 0000000..693b17f
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/project_objects.proto
@@ -0,0 +1,543 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for projects and their resources.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/protobuf/timestamp.proto";
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/issue_objects.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/permission_objects.proto";
+
+// The top level organization of issues in Monorail.
+//
+// See monorail/doc/userguide/concepts.md#Projects-and-roles.
+// and monorail/doc/userguide/project-owners.md#why-does-monorail-have-projects
+// Next available tag: 5
+message Project {
+  option (google.api.resource) = {
+    type: "api.crbug.com/Project"
+    pattern: "projects/{project}"
+  };
+
+  // Resource name of the project.
+  string name = 1;
+  // Display name of the project.
+  string display_name = 2 [(google.api.field_behavior) = IMMUTABLE];
+  // Summary of the project, ie describing what its use and purpose.
+  string summary = 3;
+  // URL pointing to this project's logo image.
+  string thumbnail_url = 4;
+}
+
+// Potential steps along the development process that an issue can be in.
+//
+// See monorail/doc/userguide/project-owners.md#How-to-configure-statuses
+// (-- aip.dev/not-precedent: "Status" should be reserved for HTTP/gRPC codes
+//     per aip.dev/216. Monorail's Status  preceded the AIP standards, and is
+//     used extensively throughout the system.)
+// Next available tag: 7
+message StatusDef {
+  option (google.api.resource) = {
+    type: "api.crbug.com/StatusDef"
+    pattern: "projects/{project}/statusDefs/{status_def}"
+  };
+
+  // Type of this status.
+  // Next available tag: 4
+  enum StatusDefType {
+    // Default enum value. This value is unused.
+    STATUS_DEF_TYPE_UNSPECIFIED = 0;
+    // This status means issue is open.
+    OPEN = 1;
+    // This status means issue is closed.
+    CLOSED = 2;
+    // This status means issue is merged into another.
+    MERGED = 3;
+  }
+
+  // State of this status.
+  // Next available tag: 3
+  enum StatusDefState {
+    // Default value. This value is unused.
+    STATUS_DEF_STATE_UNSPECIFIED = 0;
+    // This status is deprecated
+    DEPRECATED = 1;
+    // This status is not deprecated
+    ACTIVE = 2;
+  }
+
+  // Resource name of the status.
+  string name = 1;
+  // String value of the status.
+  string value = 2;
+  // Type of this status.
+  StatusDefType type = 3;
+  // Sorting rank of this status. If we sort issues by status
+  // this rank determines the sort order rather than status value.
+  uint32 rank = 4;
+  // Brief explanation of this status.
+  string docstring = 5;
+  // State of this status.
+  StatusDefState state = 6;
+}
+
+// Well-known labels that can be applied to issues within the project.
+//
+// See monorail/doc/userguide/concepts.md#issue-fields-and-labels.
+// Next available tag: 5
+// Labels defined in this project.
+message LabelDef {
+
+  option (google.api.resource) = {
+    type: "api.crbug.com/LabelDef"
+    pattern: "projects/{project}/labelDefs/{label_def}"
+  };
+
+  // State of this label.
+  // Next available tag: 3
+  enum LabelDefState {
+    // Default enum value. This value is unused.
+    LABEL_DEF_STATE_UNSPECIFIED = 0;
+    // This label is deprecated
+    DEPRECATED = 1;
+    // This label is not deprecated
+    ACTIVE = 2;
+  }
+
+  // Resource name of the label.
+  string name = 1;
+  // String value of the label.
+  string value = 2;
+  // Brief explanation of this label.
+  string docstring = 3;
+  // State of this label.
+  LabelDefState state = 4;
+}
+
+// Custom fields defined for the project.
+//
+// See monorail/doc/userguide/concepts.md#issue-fields-and-labels.
+// Check bugs.chromium.org/p/{project}/adminLabels to see the FieldDef IDs.
+// If your code needs to call multiple monorail instances
+// (e.g. monorail-{prod|staging|dev}) FieldDef IDs for FieldDefs
+// with the same display_name will differ between each monorail
+// instance. To see what FieldDef ID to use when calling staging
+// you must check bugs-staging.chromium.org/p/{project}/adminLabels.
+// Next available tag: 15
+message FieldDef {
+  option (google.api.resource) = {
+    type: "api.crbug.com/FieldDef"
+    pattern: "projects/{project}/fieldDefs/{field_def_id}"
+  };
+
+  // Resource name of the field.
+  string name = 1;
+  // Display name of the field.
+  string display_name = 2 [(google.api.field_behavior) = IMMUTABLE];
+  // Brief explanation of this field.
+  string docstring = 3;
+  // Type of this field.
+  // Next available tag: 7
+  enum Type {
+    // Default enum value. This value is unused.
+    TYPE_UNSPECIFIED = 0;
+    // This field can be filled only with enumerated option(s).
+    ENUM = 1;
+    // This field can be filled with integer(s).
+    INT = 2;
+    // This field can be filled with string(s).
+    STR = 3;
+    // This field can be filled with user(s).
+    USER = 4;
+    // This field can be filled with date(s).
+    DATE = 5;
+    // This field can be filled with URL(s).
+    URL = 6;
+  }
+  Type type = 4 [(google.api.field_behavior) = IMMUTABLE];
+
+  // Type of issue this field applies: ie Bug or Enhancement.
+  // Note: type is indicated by any "Type-foo" label or "Type" custom field.
+  string applicable_issue_type = 5;
+  // Administrators of this field.
+  repeated string admins = 6 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+
+  // Traits of this field, ie is required or can support multiple values.
+  // Next available tag: 6
+  enum Traits {
+    // Default enum value. This value is unused.
+    TRAITS_UNSPECIFIED = 0;
+    // This field must be filled out in issues where it's applicable.
+    REQUIRED = 1;
+    // This field defaults to hidden.
+    DEFAULT_HIDDEN = 2;
+    // This field can have multiple values.
+    MULTIVALUED = 3;
+    // This is a phase field, meaning it is repeated for each phase of an
+    // approval process. It cannot be the child of a particular approval.
+    PHASE = 4;
+    // Values of this field can only be edited in issues/templates by editors.
+    // Project owners and field admins are not subject of this restriction.
+    RESTRICTED = 5;
+  }
+  repeated Traits traits = 7;
+
+  // ApprovalDef that this field belongs to, if applicable.
+  // A field may not both have `approval_parent` set and have the PHASE trait.
+  string approval_parent = 8 [
+      (google.api.resource_reference) = { type: "api.crbug.com/ApprovalDef" },
+      (google.api.field_behavior) = IMMUTABLE
+  ];
+
+  // Settings specific to enum type fields.
+  // Next available tag: 2
+  message EnumTypeSettings {
+    // One available choice for an enum field.
+    // Next available tag: 3
+    message Choice {
+      // Value of this choice.
+      string value = 1;
+      // Brief explanation of this choice.
+      string docstring = 2;
+    }
+    repeated Choice choices = 1;
+  }
+  EnumTypeSettings enum_settings = 9;
+
+  // Settings specific to int type fields.
+  // Next available tag: 3
+  message IntTypeSettings {
+    // Minimum value that this field can have.
+    int32 min_value = 1;
+    // Maximum value that this field can have.
+    int32 max_value = 2;
+  }
+  IntTypeSettings int_settings = 10;
+
+  // Settings specific to str type fields.
+  // Next available tag: 2
+  message StrTypeSettings {
+    // Regex that this field value(s) must match.
+    string regex = 1;
+  }
+  StrTypeSettings str_settings = 11;
+
+  // Settings specific to user type fields.
+  // Next available tag: 5
+  message UserTypeSettings {
+    // Event that triggers a notification.
+    // Next available tag: 3
+    enum NotifyTriggers {
+      // Default notify trigger value. This value is unused.
+      NOTIFY_TRIGGERS_UNSPECIFIED = 0;
+      // There are no notifications.
+      NEVER = 1;
+      // Notify whenever any comment is made.
+      ANY_COMMENT = 2;
+    }
+    NotifyTriggers notify_triggers = 1;
+    // Field value(s) can only be set to users that fulfill the role
+    // requirements.
+    // Next available tag: 3
+    enum RoleRequirements {
+      // Default role requirement value. This value is unused.
+      ROLE_REQUIREMENTS_UNSPECIFIED = 0;
+      // There is no requirement.
+      NO_ROLE_REQUIREMENT = 1;
+      // Field value(s) can only be set to users who are members.
+      PROJECT_MEMBER = 2;
+    }
+    RoleRequirements role_requirements = 2;
+    // User(s) named in this field are granted this permission in the issue.
+    string grants_perm = 3;
+    // Field value(s) can only be set to users with this permission.
+    string needs_perm = 4;
+  }
+  UserTypeSettings user_settings = 12;
+
+  // Settings specific to date type fields.
+  // Next available tag: 2
+  message DateTypeSettings {
+    // Action to do when a date field value arrives.
+    // Next available tag: 4
+    enum DateAction {
+      // Default date action value. This value is unused.
+      DATE_ACTION_UNSPECIFIED = 0;
+      // No action will be taken when a date arrives.
+      NO_ACTION = 1;
+      // Notify owner only when a date arrives.
+      NOTIFY_OWNER = 2;
+      // Notify all participants when a date arrives.
+      NOTIFY_PARTICIPANTS = 3;
+    }
+    DateAction date_action = 1;
+  }
+  DateTypeSettings date_settings = 13;
+
+  // Editors of this field, only for RESTRICTED fields.
+  repeated string editors = 14 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+}
+
+// A high level definition of the part of the software affected by an issue.
+//
+// See monorail/doc/userguide/project-owners.md#how-to-configure-components.
+// Check crbug.com/p/{project}/adminComponents to see the ComponenttDef IDs.
+// Next available tag: 12
+message ComponentDef {
+  option (google.api.resource) = {
+    type: "api.crbug.com/ComponentDef"
+    pattern: "projects/{project}/componentDefs/{component_def_id}"
+  };
+
+  // The current state of the component definition.
+  // Next available tag: 3
+  enum ComponentDefState {
+    // Default enum value. This value is unused.
+    COMPONENT_DEF_STATE_UNSPECIFIED = 0;
+    // This component is deprecated
+    DEPRECATED = 1;
+    // This component is not deprecated
+    ACTIVE = 2;
+  }
+
+  // Resource name of the component, aka identifier.
+  // the API will always return ComponentDef names with format:
+  // projects/{project}/componentDefs/<component_def_id>.
+  // However the API will accept ComponentDef names with formats:
+  // projects/{project}/componentDefs/<component_def_id|value>.
+  string name = 1;
+  // String value of the component, ie 'Tools>Stability' or 'Blink'.
+  string value = 2;
+  // Brief explanation of this component.
+  string docstring = 3;
+  // Administrators of this component.
+  repeated string admins = 4 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+  // Auto cc'ed users of this component.
+  repeated string ccs = 5 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+  // State of this component.
+  ComponentDefState state = 6;
+  // The user that created this component.
+  string creator = 7 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" },
+      (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+  // The user that last modified this component.
+  string modifier = 8 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" },
+      (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+  // The time this component was created.
+  google.protobuf.Timestamp create_time = 9 [
+      (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+  // The time this component was last modified.
+  google.protobuf.Timestamp modify_time = 10 [
+      (google.api.field_behavior) = OUTPUT_ONLY
+  ];
+  // Labels that auto-apply to issues in this component.
+  repeated string labels = 11;
+}
+
+// Defines approvals that issues within the project may need.
+// See monorail/doc/userguide/concepts.md#issue-approvals-and-gates and
+// monorail/doc/userguide/project-owners.md#How-to-configure-approvals
+// Check bugs.chromium.org/p/{project}/adminLabels to see the ApprovalDef IDs.
+// If your code needs to call multiple monorail instances
+// (e.g. monorail-{prod|staging|dev}) ApprovalDef IDs for ApprovalDefs
+// with the same display_name will differ between each monorail
+// instance. To see what ApprovalDef ID to use when calling staging
+// you must check bugs-staging.chromium.org/p/{project}/adminLabels.
+// Next available tag: 7
+message ApprovalDef {
+  option (google.api.resource) = {
+    type: "api.crbug.com/ApprovalDef"
+    pattern: "projects/{project}/approvalDefs/{approval_def_id}"
+  };
+
+  // Resource name of the approval.
+  string name = 1;
+  // Display name of the field.
+  string display_name = 2 [(google.api.field_behavior) = IMMUTABLE];
+  // Brief explanation of this field.
+  string docstring = 3;
+  // Information approvers need from requester.
+  // May be adjusted on the issue after creation.
+  string survey = 4;
+  // Default list of users who can approve this field.
+  // May be adjusted on the issue after creation.
+  repeated string approvers = 5 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+  // Administrators of this field.
+  repeated string admins = 6 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }
+  ];
+}
+
+
+// Defines saved queries that belong to a project
+//
+// Next available tag: 4
+message ProjectSavedQuery {
+  option (google.api.resource) = {
+    type: "api.crbug.com/ProjectSavedQuery"
+    pattern: "projects/{project}/savedQueries/{saved_query_id}"
+  };
+
+  // Resource name of this saved query.
+  string name = 1;
+  // Display name of this saved query, ie 'open issues'.
+  string display_name = 2;
+  // Search term of this saved query.
+  string query = 3;
+}
+
+
+// Defines a template for filling issues.
+// Next available tag: 10
+message IssueTemplate {
+  option (google.api.resource) = {
+    type: "api.crbug.com/IssueTemplate"
+    pattern: "projects/{project}/templates/{template_id}"
+  };
+  // Resource name of the template.
+  string name = 1;
+  // Display name of this template.
+  string display_name = 2 [(google.api.field_behavior) = IMMUTABLE];
+  // Canonical Issue for this template.
+  Issue issue = 3;
+  // ApprovalValues to be created with the issue when using this template.
+  repeated ApprovalValue approval_values = 9;
+  // Boolean indicating subsequent issue creation must have delta in summary.
+  bool summary_must_be_edited = 4;
+  // Visibility permission of template.
+  // Next available tag: 3
+  enum TemplatePrivacy {
+    // This value is unused.
+    TEMPLATE_PRIVACY_UNSPECIFIED = 0;
+    // Owner project members may view this template.
+    MEMBERS_ONLY = 1;
+    // Anyone on the web can view this template.
+    PUBLIC = 2;
+  }
+  TemplatePrivacy template_privacy = 5;
+  // Indicator of who if anyone should be the default owner of the issue
+  // created with this template.
+  // Next available tag: 2
+  enum DefaultOwner {
+    // There is no default owner.
+    // This value is used if the default owner is omitted.
+    DEFAULT_OWNER_UNSPECIFIED = 0;
+    // The owner should default to the Issue reporter if the reporter is a
+    // member of the project.
+    PROJECT_MEMBER_REPORTER = 1;
+  }
+  DefaultOwner default_owner = 6;
+  // Boolean indicating whether issue must have a component.
+  bool component_required = 7;
+  // Names of Users who can administer this template.
+  repeated string admins = 8 [
+      (google.api.resource_reference) = { type: "api.crbug.com/User" }];
+}
+
+
+// Defines configurations of a project
+//
+// Next available tag: 11
+message ProjectConfig {
+  option (google.api.resource) = {
+    type: "api.crbug.com/ProjectConfig"
+    pattern: "projects/{project}/config"
+  };
+
+  // Resource name of the project config.
+  string name = 1;
+  // Set of label prefixes that only apply once per issue.
+  // E.g. priority, since no issue can be both Priority-High and Priority-Low.
+  repeated string exclusive_label_prefixes = 2;
+  // Default search query for this project's members.
+  string member_default_query = 3;
+  // TODO(crbug.com/monorail/7517): consider using IssuesListColumn
+  // Default sort specification for this project.
+  string default_sort = 4;
+  // Default columns for displaying issue list for this project.
+  repeated IssuesListColumn default_columns = 5;
+  // Grid view configurations.
+  // Next available tag: 3
+  message GridViewConfig {
+    // Default column dimension in grid view for this project.
+    string default_x_attr = 1;
+    // Default row dimension in grid view for this project.
+    string default_y_attr = 2;
+  }
+  GridViewConfig project_grid_config = 6;
+  // Default template used for issue entry for members of this project.
+  string member_default_template = 7 [
+      (google.api.resource_reference) = { type: "api.crbug.com/Template" }];
+  // Default template used for issue entry for non-members of this project.
+  string non_members_default_template = 8 [
+      (google.api.resource_reference) = { type: "api.crbug.com/Template" }];
+  // URL to browse project's source code revisions for any given revnum.
+  // E.g. https://crrev.com/{revnum}
+  string revision_url_format = 9;
+  // A project's custom URL for the "New issue" link, only if specified.
+  string custom_issue_entry_url = 10;
+}
+
+// Specifies info for a member of a project.
+//
+// Next available tag: 7
+message ProjectMember {
+  // Resource name of the Project Member.
+  // projects/{project}/members/{user_id}
+  string name = 1;
+  // The role the user has in the project.
+  // Next available tag: 4
+  enum ProjectRole {
+    // The user has no role in the project.
+    PROJECT_ROLE_UNSPECIFIED = 0;
+    // The user can make any changes to the project.
+    OWNER = 1;
+    // The user may participate in the project but may not edit the project.
+    COMMITTER = 2;
+    // The user starts with the same permissions as a non-member.
+    CONTRIBUTOR = 3;
+  }
+  ProjectRole role = 2;
+  // Which built-in/standard permissions the user has set.
+  repeated Permission standard_perms = 3;
+  // Custom permissions defined for the user.
+  // eg. "Google" in "Restrict-View-Google" is an example custom permission.
+  repeated string custom_perms = 4;
+  // Annotations about a user configured by project owners.
+  // Visible to anyone who can see the project's settings.
+  string notes = 5;
+  // Whether the user should show up in autocomplete.
+  // Next available tag: 3
+  enum AutocompleteVisibility {
+    // No autocomplete visibility value specified.
+    AUTOCOMPLETE_VISIBILITY_UNSPECIFIED = 0;
+    // The user should not show up in autocomplete.
+    HIDDEN = 1;
+    // The user may show up in autocomplete.
+    SHOWN = 2;
+  }
+  AutocompleteVisibility include_in_autocomplete = 6;
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/projects.pb.go b/analysis/internal/bugs/monorail/api_proto/projects.pb.go
new file mode 100644
index 0000000..fa90f54
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/projects.pb.go
@@ -0,0 +1,1441 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/projects.proto
+
+package api_proto
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Request message for CreateFieldDef method.
+// Next available tag: 3
+type CreateFieldDefRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The project resource where this field will be created.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The field to create.
+	// It must have a display_name and a type with its corresponding settings.
+	Fielddef *FieldDef `protobuf:"bytes,2,opt,name=fielddef,proto3" json:"fielddef,omitempty"`
+}
+
+func (x *CreateFieldDefRequest) Reset() {
+	*x = CreateFieldDefRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CreateFieldDefRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateFieldDefRequest) ProtoMessage() {}
+
+func (x *CreateFieldDefRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateFieldDefRequest.ProtoReflect.Descriptor instead.
+func (*CreateFieldDefRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *CreateFieldDefRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *CreateFieldDefRequest) GetFielddef() *FieldDef {
+	if x != nil {
+		return x.Fielddef
+	}
+	return nil
+}
+
+// Request message for GetComponentDef method.
+// Next available tag: 2
+type GetComponentDefRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetComponentDefRequest) Reset() {
+	*x = GetComponentDefRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetComponentDefRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetComponentDefRequest) ProtoMessage() {}
+
+func (x *GetComponentDefRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetComponentDefRequest.ProtoReflect.Descriptor instead.
+func (*GetComponentDefRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GetComponentDefRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// Request message for CreateComponentDef method.
+// Next available tag: 3
+type CreateComponentDefRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The project resource where this component will be created.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The component to create.
+	ComponentDef *ComponentDef `protobuf:"bytes,2,opt,name=component_def,json=componentDef,proto3" json:"component_def,omitempty"`
+}
+
+func (x *CreateComponentDefRequest) Reset() {
+	*x = CreateComponentDefRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CreateComponentDefRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateComponentDefRequest) ProtoMessage() {}
+
+func (x *CreateComponentDefRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateComponentDefRequest.ProtoReflect.Descriptor instead.
+func (*CreateComponentDefRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *CreateComponentDefRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *CreateComponentDefRequest) GetComponentDef() *ComponentDef {
+	if x != nil {
+		return x.ComponentDef
+	}
+	return nil
+}
+
+// Request message for DeleteComponentDef method.
+// Next available tag: 2
+type DeleteComponentDefRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The component to delete.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *DeleteComponentDefRequest) Reset() {
+	*x = DeleteComponentDefRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *DeleteComponentDefRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DeleteComponentDefRequest) ProtoMessage() {}
+
+func (x *DeleteComponentDefRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DeleteComponentDefRequest.ProtoReflect.Descriptor instead.
+func (*DeleteComponentDefRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *DeleteComponentDefRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// Request message for ListIssueTemplates
+// Next available tag: 4
+type ListIssueTemplatesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the project these templates belong to.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `ListIssueTemplates` call.
+	// Provide this to retrieve the subsequent page.
+	// When paginating, all other parameters provided to
+	// `ListIssueTemplatesRequest` must match the call that provided the token.
+	PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *ListIssueTemplatesRequest) Reset() {
+	*x = ListIssueTemplatesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListIssueTemplatesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListIssueTemplatesRequest) ProtoMessage() {}
+
+func (x *ListIssueTemplatesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListIssueTemplatesRequest.ProtoReflect.Descriptor instead.
+func (*ListIssueTemplatesRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ListIssueTemplatesRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *ListIssueTemplatesRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *ListIssueTemplatesRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// Response message for ListIssueTemplates
+// Next available tag: 3
+type ListIssueTemplatesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Templates matching the given request.
+	Templates []*IssueTemplate `protobuf:"bytes,1,rep,name=templates,proto3" json:"templates,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *ListIssueTemplatesResponse) Reset() {
+	*x = ListIssueTemplatesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListIssueTemplatesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListIssueTemplatesResponse) ProtoMessage() {}
+
+func (x *ListIssueTemplatesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListIssueTemplatesResponse.ProtoReflect.Descriptor instead.
+func (*ListIssueTemplatesResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ListIssueTemplatesResponse) GetTemplates() []*IssueTemplate {
+	if x != nil {
+		return x.Templates
+	}
+	return nil
+}
+
+func (x *ListIssueTemplatesResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// Request message for ListComponentDefs
+// Next available tag: 4
+type ListComponentDefsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the parent project.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `ListComponentDefs` call.
+	// Provide this to retrieve the subsequent page.
+	// When paginating, all other parameters provided to
+	// `ListComponentDefsRequest` must match the call that provided the token.
+	PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *ListComponentDefsRequest) Reset() {
+	*x = ListComponentDefsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListComponentDefsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListComponentDefsRequest) ProtoMessage() {}
+
+func (x *ListComponentDefsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListComponentDefsRequest.ProtoReflect.Descriptor instead.
+func (*ListComponentDefsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ListComponentDefsRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *ListComponentDefsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *ListComponentDefsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// Response message for ListComponentDefs
+// Next available tag: 3
+type ListComponentDefsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Component defs matching the given request.
+	ComponentDefs []*ComponentDef `protobuf:"bytes,1,rep,name=component_defs,json=componentDefs,proto3" json:"component_defs,omitempty"`
+	// A token which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *ListComponentDefsResponse) Reset() {
+	*x = ListComponentDefsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListComponentDefsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListComponentDefsResponse) ProtoMessage() {}
+
+func (x *ListComponentDefsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListComponentDefsResponse.ProtoReflect.Descriptor instead.
+func (*ListComponentDefsResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *ListComponentDefsResponse) GetComponentDefs() []*ComponentDef {
+	if x != nil {
+		return x.ComponentDefs
+	}
+	return nil
+}
+
+func (x *ListComponentDefsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// Request message for ListProjects
+// Next available tag: 3
+type ListProjectsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	PageSize int32 `protobuf:"varint,1,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `ListProjects` call.
+	// Provide this to retrieve the subsequent page.
+	PageToken string `protobuf:"bytes,2,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *ListProjectsRequest) Reset() {
+	*x = ListProjectsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListProjectsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListProjectsRequest) ProtoMessage() {}
+
+func (x *ListProjectsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListProjectsRequest.ProtoReflect.Descriptor instead.
+func (*ListProjectsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *ListProjectsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *ListProjectsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// Response message for ListProjects
+// Next available tag: 3
+type ListProjectsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Projects matching the given request.
+	Projects []*Project `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *ListProjectsResponse) Reset() {
+	*x = ListProjectsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListProjectsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListProjectsResponse) ProtoMessage() {}
+
+func (x *ListProjectsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListProjectsResponse.ProtoReflect.Descriptor instead.
+func (*ListProjectsResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *ListProjectsResponse) GetProjects() []*Project {
+	if x != nil {
+		return x.Projects
+	}
+	return nil
+}
+
+func (x *ListProjectsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDesc = []byte{
+	0x0a, 0x4d, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
+	0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x1a, 0x1b, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d,
+	0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+	0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61,
+	0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x54, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69,
+	0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c,
+	0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75,
+	0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6f, 0x62,
+	0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x86, 0x01, 0x0a, 0x15,
+	0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61,
+	0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x36, 0x0a, 0x08,
+	0x66, 0x69, 0x65, 0x6c, 0x64, 0x64, 0x65, 0x66, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65,
+	0x6c, 0x64, 0x44, 0x65, 0x66, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x66, 0x69, 0x65, 0x6c,
+	0x64, 0x64, 0x65, 0x66, 0x22, 0x50, 0x0a, 0x16, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x36,
+	0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x22, 0xfa, 0x41,
+	0x1c, 0x0a, 0x1a, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0xe0, 0x41, 0x02,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x97, 0x01, 0x0a, 0x19, 0x43, 0x72, 0x65, 0x61, 0x74,
+	0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xe0, 0x41, 0x02, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70,
+	0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x43, 0x0a, 0x0d, 0x63,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x5f, 0x64, 0x65, 0x66, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x42, 0x03, 0xe0,
+	0x41, 0x02, 0x52, 0x0c, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66,
+	0x22, 0x53, 0x0a, 0x19, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e,
+	0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x36, 0x0a,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x22, 0xe0, 0x41, 0x02,
+	0xfa, 0x41, 0x1c, 0x0a, 0x1a, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63,
+	0x6f, 0x6d, 0x2f, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x8e, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72,
+	0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0xe0,
+	0x41, 0x02, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61,
+	0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70,
+	0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f,
+	0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67,
+	0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x7e, 0x0a, 0x1a, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x73,
+	0x73, 0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65,
+	0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x49, 0x73, 0x73, 0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c,
+	0x61, 0x74, 0x65, 0x52, 0x09, 0x74, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x12, 0x26,
+	0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65,
+	0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67,
+	0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x8d, 0x01, 0x0a, 0x18, 0x4c, 0x69, 0x73, 0x74, 0x43,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x73, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x35, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72,
+	0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0xe0,
+	0x41, 0x02, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61,
+	0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70,
+	0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f,
+	0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67,
+	0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x85, 0x01, 0x0a, 0x19, 0x4c, 0x69, 0x73, 0x74, 0x43,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x73, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x12, 0x40, 0x0a, 0x0e, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+	0x74, 0x5f, 0x64, 0x65, 0x66, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x0d, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65,
+	0x6e, 0x74, 0x44, 0x65, 0x66, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70,
+	0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x51,
+	0x0a, 0x13, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69,
+	0x7a, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69,
+	0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65,
+	0x6e, 0x22, 0x70, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x08, 0x70, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e,
+	0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f,
+	0x6b, 0x65, 0x6e, 0x32, 0x87, 0x05, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73,
+	0x12, 0x4d, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44,
+	0x65, 0x66, 0x12, 0x22, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x15, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x44, 0x65, 0x66, 0x22, 0x00, 0x12,
+	0x53, 0x0a, 0x0f, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44,
+	0x65, 0x66, 0x12, 0x23, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x47, 0x65, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44,
+	0x65, 0x66, 0x22, 0x00, 0x12, 0x59, 0x0a, 0x12, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x6f,
+	0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x12, 0x26, 0x2e, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x19, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x22, 0x00, 0x12,
+	0x56, 0x0a, 0x12, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65,
+	0x6e, 0x74, 0x44, 0x65, 0x66, 0x12, 0x26, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e,
+	0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x67, 0x0a, 0x12, 0x4c, 0x69, 0x73, 0x74, 0x49,
+	0x73, 0x73, 0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x12, 0x26, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74,
+	0x49, 0x73, 0x73, 0x75, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x49, 0x73, 0x73, 0x75, 0x65, 0x54, 0x65, 0x6d,
+	0x70, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x64, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+	0x74, 0x44, 0x65, 0x66, 0x73, 0x12, 0x25, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e,
+	0x74, 0x44, 0x65, 0x66, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43,
+	0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x44, 0x65, 0x66, 0x73, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x55, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x20, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69,
+	0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x40, 0x5a,
+	0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67,
+	0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69,
+	0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e,
+	0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_goTypes = []interface{}{
+	(*CreateFieldDefRequest)(nil),      // 0: monorail.v3.CreateFieldDefRequest
+	(*GetComponentDefRequest)(nil),     // 1: monorail.v3.GetComponentDefRequest
+	(*CreateComponentDefRequest)(nil),  // 2: monorail.v3.CreateComponentDefRequest
+	(*DeleteComponentDefRequest)(nil),  // 3: monorail.v3.DeleteComponentDefRequest
+	(*ListIssueTemplatesRequest)(nil),  // 4: monorail.v3.ListIssueTemplatesRequest
+	(*ListIssueTemplatesResponse)(nil), // 5: monorail.v3.ListIssueTemplatesResponse
+	(*ListComponentDefsRequest)(nil),   // 6: monorail.v3.ListComponentDefsRequest
+	(*ListComponentDefsResponse)(nil),  // 7: monorail.v3.ListComponentDefsResponse
+	(*ListProjectsRequest)(nil),        // 8: monorail.v3.ListProjectsRequest
+	(*ListProjectsResponse)(nil),       // 9: monorail.v3.ListProjectsResponse
+	(*FieldDef)(nil),                   // 10: monorail.v3.FieldDef
+	(*ComponentDef)(nil),               // 11: monorail.v3.ComponentDef
+	(*IssueTemplate)(nil),              // 12: monorail.v3.IssueTemplate
+	(*Project)(nil),                    // 13: monorail.v3.Project
+	(*emptypb.Empty)(nil),              // 14: google.protobuf.Empty
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_depIdxs = []int32{
+	10, // 0: monorail.v3.CreateFieldDefRequest.fielddef:type_name -> monorail.v3.FieldDef
+	11, // 1: monorail.v3.CreateComponentDefRequest.component_def:type_name -> monorail.v3.ComponentDef
+	12, // 2: monorail.v3.ListIssueTemplatesResponse.templates:type_name -> monorail.v3.IssueTemplate
+	11, // 3: monorail.v3.ListComponentDefsResponse.component_defs:type_name -> monorail.v3.ComponentDef
+	13, // 4: monorail.v3.ListProjectsResponse.projects:type_name -> monorail.v3.Project
+	0,  // 5: monorail.v3.Projects.CreateFieldDef:input_type -> monorail.v3.CreateFieldDefRequest
+	1,  // 6: monorail.v3.Projects.GetComponentDef:input_type -> monorail.v3.GetComponentDefRequest
+	2,  // 7: monorail.v3.Projects.CreateComponentDef:input_type -> monorail.v3.CreateComponentDefRequest
+	3,  // 8: monorail.v3.Projects.DeleteComponentDef:input_type -> monorail.v3.DeleteComponentDefRequest
+	4,  // 9: monorail.v3.Projects.ListIssueTemplates:input_type -> monorail.v3.ListIssueTemplatesRequest
+	6,  // 10: monorail.v3.Projects.ListComponentDefs:input_type -> monorail.v3.ListComponentDefsRequest
+	8,  // 11: monorail.v3.Projects.ListProjects:input_type -> monorail.v3.ListProjectsRequest
+	10, // 12: monorail.v3.Projects.CreateFieldDef:output_type -> monorail.v3.FieldDef
+	11, // 13: monorail.v3.Projects.GetComponentDef:output_type -> monorail.v3.ComponentDef
+	11, // 14: monorail.v3.Projects.CreateComponentDef:output_type -> monorail.v3.ComponentDef
+	14, // 15: monorail.v3.Projects.DeleteComponentDef:output_type -> google.protobuf.Empty
+	5,  // 16: monorail.v3.Projects.ListIssueTemplates:output_type -> monorail.v3.ListIssueTemplatesResponse
+	7,  // 17: monorail.v3.Projects.ListComponentDefs:output_type -> monorail.v3.ListComponentDefsResponse
+	9,  // 18: monorail.v3.Projects.ListProjects:output_type -> monorail.v3.ListProjectsResponse
+	12, // [12:19] is the sub-list for method output_type
+	5,  // [5:12] is the sub-list for method input_type
+	5,  // [5:5] is the sub-list for extension type_name
+	5,  // [5:5] is the sub-list for extension extendee
+	0,  // [0:5] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_project_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CreateFieldDefRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetComponentDefRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CreateComponentDefRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*DeleteComponentDefRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListIssueTemplatesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListIssueTemplatesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListComponentDefsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListComponentDefsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListProjectsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListProjectsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   10,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_depIdxs,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_projects_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// ProjectsClient is the client API for Projects service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type ProjectsClient interface {
+	// status: NOT READY
+	// Creates a new FieldDef (custom field).
+	//
+	// Raises:
+	//   NOT_FOUND if some given users do not exist.
+	//   ALREADY_EXISTS if a field with the same name owned by the project
+	//   already exists.
+	//   INVALID_INPUT if there was a problem with the input.
+	//   PERMISSION_DENIED if the user cannot edit the project.
+	CreateFieldDef(ctx context.Context, in *CreateFieldDefRequest, opts ...grpc.CallOption) (*FieldDef, error)
+	// status: ALPHA
+	// Gets a ComponentDef given the reference.
+	//
+	// Raises:
+	//   INVALID_INPUT if the request is invalid.
+	//   NOT_FOUND if the parent project or the component is not found.
+	GetComponentDef(ctx context.Context, in *GetComponentDefRequest, opts ...grpc.CallOption) (*ComponentDef, error)
+	// status: ALPHA
+	// Creates a new ComponentDef.
+	//
+	// Raises:
+	//   INVALID_INPUT if the request is invalid.
+	//   ALREADY_EXISTS if the component already exists.
+	//   PERMISSION_DENIED if the user is not allowed to create a/this component.
+	//   NOT_FOUND if the parent project or a component cc or admin is not found.
+	CreateComponentDef(ctx context.Context, in *CreateComponentDefRequest, opts ...grpc.CallOption) (*ComponentDef, error)
+	// status: ALPHA
+	// Deletes a ComponentDef.
+	//
+	// Raises:
+	//   INVALID_INPUT if the request is invalid.
+	//   PERMISSION_DENIED if the user is not allowed to delete a/this component.
+	//   NOT_FOUND if the component or project is not found.
+	DeleteComponentDef(ctx context.Context, in *DeleteComponentDefRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Returns all templates for specified project.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested parent project is not found.
+	//   INVALID_ARGUMENT if the given `parent` is not valid.
+	ListIssueTemplates(ctx context.Context, in *ListIssueTemplatesRequest, opts ...grpc.CallOption) (*ListIssueTemplatesResponse, error)
+	// status: ALPHA
+	// Returns all field defs for specified project.
+	//
+	// Raises:
+	//   NOT_FOUND if the request arent project is not found.
+	//   INVALID_ARGUMENT if the given `parent` is not valid.
+	ListComponentDefs(ctx context.Context, in *ListComponentDefsRequest, opts ...grpc.CallOption) (*ListComponentDefsResponse, error)
+	// status: NOT READY
+	// Returns all projects hosted on Monorail.
+	ListProjects(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ListProjectsResponse, error)
+}
+type projectsPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewProjectsPRPCClient(client *prpc.Client) ProjectsClient {
+	return &projectsPRPCClient{client}
+}
+
+func (c *projectsPRPCClient) CreateFieldDef(ctx context.Context, in *CreateFieldDefRequest, opts ...grpc.CallOption) (*FieldDef, error) {
+	out := new(FieldDef)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "CreateFieldDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) GetComponentDef(ctx context.Context, in *GetComponentDefRequest, opts ...grpc.CallOption) (*ComponentDef, error) {
+	out := new(ComponentDef)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "GetComponentDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) CreateComponentDef(ctx context.Context, in *CreateComponentDefRequest, opts ...grpc.CallOption) (*ComponentDef, error) {
+	out := new(ComponentDef)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "CreateComponentDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) DeleteComponentDef(ctx context.Context, in *DeleteComponentDefRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "DeleteComponentDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) ListIssueTemplates(ctx context.Context, in *ListIssueTemplatesRequest, opts ...grpc.CallOption) (*ListIssueTemplatesResponse, error) {
+	out := new(ListIssueTemplatesResponse)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "ListIssueTemplates", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) ListComponentDefs(ctx context.Context, in *ListComponentDefsRequest, opts ...grpc.CallOption) (*ListComponentDefsResponse, error) {
+	out := new(ListComponentDefsResponse)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "ListComponentDefs", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) ListProjects(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ListProjectsResponse, error) {
+	out := new(ListProjectsResponse)
+	err := c.client.Call(ctx, "monorail.v3.Projects", "ListProjects", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type projectsClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewProjectsClient(cc grpc.ClientConnInterface) ProjectsClient {
+	return &projectsClient{cc}
+}
+
+func (c *projectsClient) CreateFieldDef(ctx context.Context, in *CreateFieldDefRequest, opts ...grpc.CallOption) (*FieldDef, error) {
+	out := new(FieldDef)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/CreateFieldDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) GetComponentDef(ctx context.Context, in *GetComponentDefRequest, opts ...grpc.CallOption) (*ComponentDef, error) {
+	out := new(ComponentDef)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/GetComponentDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) CreateComponentDef(ctx context.Context, in *CreateComponentDefRequest, opts ...grpc.CallOption) (*ComponentDef, error) {
+	out := new(ComponentDef)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/CreateComponentDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) DeleteComponentDef(ctx context.Context, in *DeleteComponentDefRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/DeleteComponentDef", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) ListIssueTemplates(ctx context.Context, in *ListIssueTemplatesRequest, opts ...grpc.CallOption) (*ListIssueTemplatesResponse, error) {
+	out := new(ListIssueTemplatesResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/ListIssueTemplates", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) ListComponentDefs(ctx context.Context, in *ListComponentDefsRequest, opts ...grpc.CallOption) (*ListComponentDefsResponse, error) {
+	out := new(ListComponentDefsResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/ListComponentDefs", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) ListProjects(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ListProjectsResponse, error) {
+	out := new(ListProjectsResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Projects/ListProjects", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ProjectsServer is the server API for Projects service.
+type ProjectsServer interface {
+	// status: NOT READY
+	// Creates a new FieldDef (custom field).
+	//
+	// Raises:
+	//   NOT_FOUND if some given users do not exist.
+	//   ALREADY_EXISTS if a field with the same name owned by the project
+	//   already exists.
+	//   INVALID_INPUT if there was a problem with the input.
+	//   PERMISSION_DENIED if the user cannot edit the project.
+	CreateFieldDef(context.Context, *CreateFieldDefRequest) (*FieldDef, error)
+	// status: ALPHA
+	// Gets a ComponentDef given the reference.
+	//
+	// Raises:
+	//   INVALID_INPUT if the request is invalid.
+	//   NOT_FOUND if the parent project or the component is not found.
+	GetComponentDef(context.Context, *GetComponentDefRequest) (*ComponentDef, error)
+	// status: ALPHA
+	// Creates a new ComponentDef.
+	//
+	// Raises:
+	//   INVALID_INPUT if the request is invalid.
+	//   ALREADY_EXISTS if the component already exists.
+	//   PERMISSION_DENIED if the user is not allowed to create a/this component.
+	//   NOT_FOUND if the parent project or a component cc or admin is not found.
+	CreateComponentDef(context.Context, *CreateComponentDefRequest) (*ComponentDef, error)
+	// status: ALPHA
+	// Deletes a ComponentDef.
+	//
+	// Raises:
+	//   INVALID_INPUT if the request is invalid.
+	//   PERMISSION_DENIED if the user is not allowed to delete a/this component.
+	//   NOT_FOUND if the component or project is not found.
+	DeleteComponentDef(context.Context, *DeleteComponentDefRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Returns all templates for specified project.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested parent project is not found.
+	//   INVALID_ARGUMENT if the given `parent` is not valid.
+	ListIssueTemplates(context.Context, *ListIssueTemplatesRequest) (*ListIssueTemplatesResponse, error)
+	// status: ALPHA
+	// Returns all field defs for specified project.
+	//
+	// Raises:
+	//   NOT_FOUND if the request arent project is not found.
+	//   INVALID_ARGUMENT if the given `parent` is not valid.
+	ListComponentDefs(context.Context, *ListComponentDefsRequest) (*ListComponentDefsResponse, error)
+	// status: NOT READY
+	// Returns all projects hosted on Monorail.
+	ListProjects(context.Context, *ListProjectsRequest) (*ListProjectsResponse, error)
+}
+
+// UnimplementedProjectsServer can be embedded to have forward compatible implementations.
+type UnimplementedProjectsServer struct {
+}
+
+func (*UnimplementedProjectsServer) CreateFieldDef(context.Context, *CreateFieldDefRequest) (*FieldDef, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method CreateFieldDef not implemented")
+}
+func (*UnimplementedProjectsServer) GetComponentDef(context.Context, *GetComponentDefRequest) (*ComponentDef, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetComponentDef not implemented")
+}
+func (*UnimplementedProjectsServer) CreateComponentDef(context.Context, *CreateComponentDefRequest) (*ComponentDef, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method CreateComponentDef not implemented")
+}
+func (*UnimplementedProjectsServer) DeleteComponentDef(context.Context, *DeleteComponentDefRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method DeleteComponentDef not implemented")
+}
+func (*UnimplementedProjectsServer) ListIssueTemplates(context.Context, *ListIssueTemplatesRequest) (*ListIssueTemplatesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListIssueTemplates not implemented")
+}
+func (*UnimplementedProjectsServer) ListComponentDefs(context.Context, *ListComponentDefsRequest) (*ListComponentDefsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListComponentDefs not implemented")
+}
+func (*UnimplementedProjectsServer) ListProjects(context.Context, *ListProjectsRequest) (*ListProjectsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListProjects not implemented")
+}
+
+func RegisterProjectsServer(s prpc.Registrar, srv ProjectsServer) {
+	s.RegisterService(&_Projects_serviceDesc, srv)
+}
+
+func _Projects_CreateFieldDef_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(CreateFieldDefRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).CreateFieldDef(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/CreateFieldDef",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).CreateFieldDef(ctx, req.(*CreateFieldDefRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_GetComponentDef_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetComponentDefRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).GetComponentDef(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/GetComponentDef",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).GetComponentDef(ctx, req.(*GetComponentDefRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_CreateComponentDef_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(CreateComponentDefRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).CreateComponentDef(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/CreateComponentDef",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).CreateComponentDef(ctx, req.(*CreateComponentDefRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_DeleteComponentDef_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(DeleteComponentDefRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).DeleteComponentDef(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/DeleteComponentDef",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).DeleteComponentDef(ctx, req.(*DeleteComponentDefRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_ListIssueTemplates_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListIssueTemplatesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).ListIssueTemplates(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/ListIssueTemplates",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).ListIssueTemplates(ctx, req.(*ListIssueTemplatesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_ListComponentDefs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListComponentDefsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).ListComponentDefs(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/ListComponentDefs",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).ListComponentDefs(ctx, req.(*ListComponentDefsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_ListProjects_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListProjectsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).ListProjects(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Projects/ListProjects",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).ListProjects(ctx, req.(*ListProjectsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Projects_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "monorail.v3.Projects",
+	HandlerType: (*ProjectsServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "CreateFieldDef",
+			Handler:    _Projects_CreateFieldDef_Handler,
+		},
+		{
+			MethodName: "GetComponentDef",
+			Handler:    _Projects_GetComponentDef_Handler,
+		},
+		{
+			MethodName: "CreateComponentDef",
+			Handler:    _Projects_CreateComponentDef_Handler,
+		},
+		{
+			MethodName: "DeleteComponentDef",
+			Handler:    _Projects_DeleteComponentDef_Handler,
+		},
+		{
+			MethodName: "ListIssueTemplates",
+			Handler:    _Projects_ListIssueTemplates_Handler,
+		},
+		{
+			MethodName: "ListComponentDefs",
+			Handler:    _Projects_ListComponentDefs_Handler,
+		},
+		{
+			MethodName: "ListProjects",
+			Handler:    _Projects_ListProjects_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/projects.proto",
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/projects.proto b/analysis/internal/bugs/monorail/api_proto/projects.proto
new file mode 100644
index 0000000..5435ca4
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/projects.proto
@@ -0,0 +1,194 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/protobuf/empty.proto";
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/project_objects.proto";
+
+// ***ONLY CALL rpcs WITH `status: {ALPHA|STABLE}`***
+// rpcs without `status` are not ready.
+
+// Projects service includes all methods needed for managing Projects.
+service Projects {
+  // status: NOT READY
+  // Creates a new FieldDef (custom field).
+  //
+  // Raises:
+  //   NOT_FOUND if some given users do not exist.
+  //   ALREADY_EXISTS if a field with the same name owned by the project
+  //   already exists.
+  //   INVALID_INPUT if there was a problem with the input.
+  //   PERMISSION_DENIED if the user cannot edit the project.
+  rpc CreateFieldDef (CreateFieldDefRequest) returns (FieldDef) {}
+
+  // status: ALPHA
+  // Gets a ComponentDef given the reference.
+  //
+  // Raises:
+  //   INVALID_INPUT if the request is invalid.
+  //   NOT_FOUND if the parent project or the component is not found.
+  rpc GetComponentDef (GetComponentDefRequest) returns (ComponentDef) {}
+
+  // status: ALPHA
+  // Creates a new ComponentDef.
+  //
+  // Raises:
+  //   INVALID_INPUT if the request is invalid.
+  //   ALREADY_EXISTS if the component already exists.
+  //   PERMISSION_DENIED if the user is not allowed to create a/this component.
+  //   NOT_FOUND if the parent project or a component cc or admin is not found.
+  rpc CreateComponentDef (CreateComponentDefRequest) returns (ComponentDef) {}
+
+  // status: ALPHA
+  // Deletes a ComponentDef.
+  //
+  // Raises:
+  //   INVALID_INPUT if the request is invalid.
+  //   PERMISSION_DENIED if the user is not allowed to delete a/this component.
+  //   NOT_FOUND if the component or project is not found.
+  rpc DeleteComponentDef (DeleteComponentDefRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Returns all templates for specified project.
+  //
+  // Raises:
+  //   NOT_FOUND if the requested parent project is not found.
+  //   INVALID_ARGUMENT if the given `parent` is not valid.
+  rpc ListIssueTemplates (ListIssueTemplatesRequest) returns (ListIssueTemplatesResponse) {}
+
+  // status: ALPHA
+  // Returns all field defs for specified project.
+  //
+  // Raises:
+  //   NOT_FOUND if the request arent project is not found.
+  //   INVALID_ARGUMENT if the given `parent` is not valid.
+  rpc ListComponentDefs (ListComponentDefsRequest) returns (ListComponentDefsResponse) {}
+
+  // status: NOT READY
+  // Returns all projects hosted on Monorail.
+  rpc ListProjects (ListProjectsRequest) returns (ListProjectsResponse) {}
+}
+
+// Request message for CreateFieldDef method.
+// Next available tag: 3
+message CreateFieldDefRequest {
+  // The project resource where this field will be created.
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {type: "api.crbug.com/Project" }];
+  // The field to create.
+  // It must have a display_name and a type with its corresponding settings.
+  FieldDef fielddef = 2 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+// Request message for GetComponentDef method.
+// Next available tag: 2
+message GetComponentDefRequest {
+  string name = 1 [
+    (google.api.resource_reference) = { type: "api.crbug.com/ComponentDef" },
+    (google.api.field_behavior) = REQUIRED ];
+}
+
+// Request message for CreateComponentDef method.
+// Next available tag: 3
+message CreateComponentDefRequest {
+  // The project resource where this component will be created.
+  string parent = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {type: "api.crbug.com/Project" }];
+  // The component to create.
+  ComponentDef component_def = 2 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+// Request message for DeleteComponentDef method.
+// Next available tag: 2
+message DeleteComponentDefRequest {
+  // The component to delete.
+  string name = 1 [
+    (google.api.field_behavior) = REQUIRED,
+    (google.api.resource_reference) = {type: "api.crbug.com/ComponentDef"}];
+}
+
+// Request message for ListIssueTemplates
+// Next available tag: 4
+message ListIssueTemplatesRequest {
+  // The name of the project these templates belong to.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+    (google.api.field_behavior) = REQUIRED ];
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  int32 page_size = 2;
+  // A page token, received from a previous `ListIssueTemplates` call.
+  // Provide this to retrieve the subsequent page.
+  // When paginating, all other parameters provided to
+  // `ListIssueTemplatesRequest` must match the call that provided the token.
+  string page_token = 3;
+}
+
+// Response message for ListIssueTemplates
+// Next available tag: 3
+message ListIssueTemplatesResponse {
+  // Templates matching the given request.
+  repeated IssueTemplate templates = 1;
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for ListComponentDefs
+// Next available tag: 4
+message ListComponentDefsRequest {
+  // The name of the parent project.
+  string parent = 1 [
+    (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+    (google.api.field_behavior) = REQUIRED ];
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  int32 page_size = 2;
+  // A page token, received from a previous `ListComponentDefs` call.
+  // Provide this to retrieve the subsequent page.
+  // When paginating, all other parameters provided to
+  // `ListComponentDefsRequest` must match the call that provided the token.
+  string page_token = 3;
+}
+
+// Response message for ListComponentDefs
+// Next available tag: 3
+message ListComponentDefsResponse {
+  // Component defs matching the given request.
+  repeated ComponentDef component_defs = 1;
+  // A token which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
+
+// Request message for ListProjects
+// Next available tag: 3
+message ListProjectsRequest {
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  int32 page_size = 1;
+  // A page token, received from a previous `ListProjects` call.
+  // Provide this to retrieve the subsequent page.
+  string page_token = 2;
+}
+
+// Response message for ListProjects
+// Next available tag: 3
+message ListProjectsResponse {
+  // Projects matching the given request.
+  repeated Project projects = 1;
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/user_objects.pb.go b/analysis/internal/bugs/monorail/api_proto/user_objects.pb.go
new file mode 100644
index 0000000..93cc1ce
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/user_objects.pb.go
@@ -0,0 +1,1041 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for users and related business
+// objects, e.g., users, user preferences.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/user_objects.proto
+
+package api_proto
+
+import (
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Potential roles of a user.
+// Next available tag: 3
+type UserSettings_SiteRole int32
+
+const (
+	// Default value. This value is unused.
+	UserSettings_SITE_ROLE_UNSPECIFIED UserSettings_SiteRole = 0
+	// Normal site user with no special site-wide extra permissions.
+	UserSettings_NORMAL UserSettings_SiteRole = 1
+	// Site-wide admin role.
+	UserSettings_ADMIN UserSettings_SiteRole = 2
+)
+
+// Enum value maps for UserSettings_SiteRole.
+var (
+	UserSettings_SiteRole_name = map[int32]string{
+		0: "SITE_ROLE_UNSPECIFIED",
+		1: "NORMAL",
+		2: "ADMIN",
+	}
+	UserSettings_SiteRole_value = map[string]int32{
+		"SITE_ROLE_UNSPECIFIED": 0,
+		"NORMAL":                1,
+		"ADMIN":                 2,
+	}
+)
+
+func (x UserSettings_SiteRole) Enum() *UserSettings_SiteRole {
+	p := new(UserSettings_SiteRole)
+	*p = x
+	return p
+}
+
+func (x UserSettings_SiteRole) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UserSettings_SiteRole) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[0].Descriptor()
+}
+
+func (UserSettings_SiteRole) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[0]
+}
+
+func (x UserSettings_SiteRole) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UserSettings_SiteRole.Descriptor instead.
+func (UserSettings_SiteRole) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1, 0}
+}
+
+// Trait options for notifications the user receives.
+// Next available tag: 6;
+type UserSettings_NotificationTraits int32
+
+const (
+	// Default value. This value is unused.
+	UserSettings_NOTIFICATION_TRAITS_UNSPECIFIED UserSettings_NotificationTraits = 0
+	// Send change notifications for issues where user is owner or cc.
+	UserSettings_NOTIFY_ON_OWNED_OR_CC_ISSUE_CHANGES UserSettings_NotificationTraits = 1
+	// Send change notifications for issues the user has starred.
+	UserSettings_NOTIFY_ON_STARRED_ISSUE_CHANGES UserSettings_NotificationTraits = 2
+	// Send date-type field notifications for issues the user has starred.
+	// See monorail/doc/userguide/email.md#why-did-i-get-a-follow_up-email-notification.
+	UserSettings_NOTIFY_ON_STARRED_NOTIFY_DATES UserSettings_NotificationTraits = 3
+	// Email subject lines should be compact.
+	UserSettings_COMPACT_SUBJECT_LINE UserSettings_NotificationTraits = 4
+	// Include a button link to the issue, in Gmail.
+	UserSettings_GMAIL_INCLUDE_ISSUE_LINK_BUTTON UserSettings_NotificationTraits = 5
+)
+
+// Enum value maps for UserSettings_NotificationTraits.
+var (
+	UserSettings_NotificationTraits_name = map[int32]string{
+		0: "NOTIFICATION_TRAITS_UNSPECIFIED",
+		1: "NOTIFY_ON_OWNED_OR_CC_ISSUE_CHANGES",
+		2: "NOTIFY_ON_STARRED_ISSUE_CHANGES",
+		3: "NOTIFY_ON_STARRED_NOTIFY_DATES",
+		4: "COMPACT_SUBJECT_LINE",
+		5: "GMAIL_INCLUDE_ISSUE_LINK_BUTTON",
+	}
+	UserSettings_NotificationTraits_value = map[string]int32{
+		"NOTIFICATION_TRAITS_UNSPECIFIED":     0,
+		"NOTIFY_ON_OWNED_OR_CC_ISSUE_CHANGES": 1,
+		"NOTIFY_ON_STARRED_ISSUE_CHANGES":     2,
+		"NOTIFY_ON_STARRED_NOTIFY_DATES":      3,
+		"COMPACT_SUBJECT_LINE":                4,
+		"GMAIL_INCLUDE_ISSUE_LINK_BUTTON":     5,
+	}
+)
+
+func (x UserSettings_NotificationTraits) Enum() *UserSettings_NotificationTraits {
+	p := new(UserSettings_NotificationTraits)
+	*p = x
+	return p
+}
+
+func (x UserSettings_NotificationTraits) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UserSettings_NotificationTraits) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[1].Descriptor()
+}
+
+func (UserSettings_NotificationTraits) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[1]
+}
+
+func (x UserSettings_NotificationTraits) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UserSettings_NotificationTraits.Descriptor instead.
+func (UserSettings_NotificationTraits) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1, 1}
+}
+
+// Privacy trait options for the user.
+// Next available tag: 2
+type UserSettings_PrivacyTraits int32
+
+const (
+	// Default value. This value is unused.
+	UserSettings_PRIVACY_TRAITS_UNSPECIFIED UserSettings_PrivacyTraits = 0
+	// Obscure the user's email from non-project members throughout the site.
+	UserSettings_OBSCURE_EMAIL UserSettings_PrivacyTraits = 1
+)
+
+// Enum value maps for UserSettings_PrivacyTraits.
+var (
+	UserSettings_PrivacyTraits_name = map[int32]string{
+		0: "PRIVACY_TRAITS_UNSPECIFIED",
+		1: "OBSCURE_EMAIL",
+	}
+	UserSettings_PrivacyTraits_value = map[string]int32{
+		"PRIVACY_TRAITS_UNSPECIFIED": 0,
+		"OBSCURE_EMAIL":              1,
+	}
+)
+
+func (x UserSettings_PrivacyTraits) Enum() *UserSettings_PrivacyTraits {
+	p := new(UserSettings_PrivacyTraits)
+	*p = x
+	return p
+}
+
+func (x UserSettings_PrivacyTraits) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UserSettings_PrivacyTraits) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[2].Descriptor()
+}
+
+func (UserSettings_PrivacyTraits) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[2]
+}
+
+func (x UserSettings_PrivacyTraits) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UserSettings_PrivacyTraits.Descriptor instead.
+func (UserSettings_PrivacyTraits) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1, 2}
+}
+
+// Site interaction trait options for the user.
+// Next available tag: 3
+type UserSettings_SiteInteractionTraits int32
+
+const (
+	// Default value. This value is unused.
+	UserSettings_SITE_INTERACTION_TRAITS_UNSPECIFIED UserSettings_SiteInteractionTraits = 0
+	// Add 'Restrict-View-Google' labels to new issues the user reports.
+	// Issues will only be visible to the user (issue reporter)
+	// and users with the `Google` permission.
+	UserSettings_REPORT_RESTRICT_VIEW_GOOGLE_ISSUES UserSettings_SiteInteractionTraits = 1
+	// When viewing public issues, show a banner to remind the user not
+	// to post sensitive information.
+	UserSettings_PUBLIC_ISSUE_BANNER UserSettings_SiteInteractionTraits = 2
+)
+
+// Enum value maps for UserSettings_SiteInteractionTraits.
+var (
+	UserSettings_SiteInteractionTraits_name = map[int32]string{
+		0: "SITE_INTERACTION_TRAITS_UNSPECIFIED",
+		1: "REPORT_RESTRICT_VIEW_GOOGLE_ISSUES",
+		2: "PUBLIC_ISSUE_BANNER",
+	}
+	UserSettings_SiteInteractionTraits_value = map[string]int32{
+		"SITE_INTERACTION_TRAITS_UNSPECIFIED": 0,
+		"REPORT_RESTRICT_VIEW_GOOGLE_ISSUES":  1,
+		"PUBLIC_ISSUE_BANNER":                 2,
+	}
+)
+
+func (x UserSettings_SiteInteractionTraits) Enum() *UserSettings_SiteInteractionTraits {
+	p := new(UserSettings_SiteInteractionTraits)
+	*p = x
+	return p
+}
+
+func (x UserSettings_SiteInteractionTraits) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UserSettings_SiteInteractionTraits) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[3].Descriptor()
+}
+
+func (UserSettings_SiteInteractionTraits) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[3]
+}
+
+func (x UserSettings_SiteInteractionTraits) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UserSettings_SiteInteractionTraits.Descriptor instead.
+func (UserSettings_SiteInteractionTraits) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1, 3}
+}
+
+// Potential status of a user's access to the site.
+// Next available tag: 3
+type UserSettings_SiteAccess_Status int32
+
+const (
+	// Default value. This value is unused.
+	UserSettings_SiteAccess_STATUS_UNSPECIFIED UserSettings_SiteAccess_Status = 0
+	// The user has access to the site.
+	UserSettings_SiteAccess_FULL_ACCESS UserSettings_SiteAccess_Status = 1
+	// The user is banned from the site.
+	UserSettings_SiteAccess_BANNED UserSettings_SiteAccess_Status = 2
+)
+
+// Enum value maps for UserSettings_SiteAccess_Status.
+var (
+	UserSettings_SiteAccess_Status_name = map[int32]string{
+		0: "STATUS_UNSPECIFIED",
+		1: "FULL_ACCESS",
+		2: "BANNED",
+	}
+	UserSettings_SiteAccess_Status_value = map[string]int32{
+		"STATUS_UNSPECIFIED": 0,
+		"FULL_ACCESS":        1,
+		"BANNED":             2,
+	}
+)
+
+func (x UserSettings_SiteAccess_Status) Enum() *UserSettings_SiteAccess_Status {
+	p := new(UserSettings_SiteAccess_Status)
+	*p = x
+	return p
+}
+
+func (x UserSettings_SiteAccess_Status) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UserSettings_SiteAccess_Status) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[4].Descriptor()
+}
+
+func (UserSettings_SiteAccess_Status) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[4]
+}
+
+func (x UserSettings_SiteAccess_Status) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UserSettings_SiteAccess_Status.Descriptor instead.
+func (UserSettings_SiteAccess_Status) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1, 0, 0}
+}
+
+// Subscription mode of this saved query
+// Next available tag: 3
+type UserSavedQuery_SubscriptionMode int32
+
+const (
+	// Default API value. This value is unused.
+	UserSavedQuery_SUBSCRIPTION_MODE_UNSPECIFIED UserSavedQuery_SubscriptionMode = 0
+	// Do not subscribe to notifications.
+	UserSavedQuery_NO_NOTIFICATION UserSavedQuery_SubscriptionMode = 1
+	// Subscribe to notifications.
+	UserSavedQuery_IMMEDIATE_NOTIFICATION UserSavedQuery_SubscriptionMode = 2
+)
+
+// Enum value maps for UserSavedQuery_SubscriptionMode.
+var (
+	UserSavedQuery_SubscriptionMode_name = map[int32]string{
+		0: "SUBSCRIPTION_MODE_UNSPECIFIED",
+		1: "NO_NOTIFICATION",
+		2: "IMMEDIATE_NOTIFICATION",
+	}
+	UserSavedQuery_SubscriptionMode_value = map[string]int32{
+		"SUBSCRIPTION_MODE_UNSPECIFIED": 0,
+		"NO_NOTIFICATION":               1,
+		"IMMEDIATE_NOTIFICATION":        2,
+	}
+)
+
+func (x UserSavedQuery_SubscriptionMode) Enum() *UserSavedQuery_SubscriptionMode {
+	p := new(UserSavedQuery_SubscriptionMode)
+	*p = x
+	return p
+}
+
+func (x UserSavedQuery_SubscriptionMode) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (UserSavedQuery_SubscriptionMode) Descriptor() protoreflect.EnumDescriptor {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[5].Descriptor()
+}
+
+func (UserSavedQuery_SubscriptionMode) Type() protoreflect.EnumType {
+	return &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes[5]
+}
+
+func (x UserSavedQuery_SubscriptionMode) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use UserSavedQuery_SubscriptionMode.Descriptor instead.
+func (UserSavedQuery_SubscriptionMode) EnumDescriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{2, 0}
+}
+
+// User represents a user of the Monorail site.
+// Next available tag: 5
+type User struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the user.
+	// The API will always return User names with format: users/<user_id>.
+	// However the API will accept User names with formats: users/<user_id> or users/<email>.
+	// To fetch the display_name for any users/<user_id> returned by the API,
+	// you can call {Batch}GetUser{s}.
+	// We represent deleted users within Monorail with `users/1` or `users/2103649657`.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// User display_name to show other users using the site.
+	// By default this is the obscured or un-obscured email.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Obscured or un-obscured user email or empty if this represents
+	// a deleted user.
+	Email string `protobuf:"bytes,4,opt,name=email,proto3" json:"email,omitempty"`
+	// User-written indication of their availability or working hours.
+	AvailabilityMessage string `protobuf:"bytes,3,opt,name=availability_message,json=availabilityMessage,proto3" json:"availability_message,omitempty"`
+	// Timestamp of the user's last visit
+	LastVisitTimestamp int32 `protobuf:"varint,5,opt,name=last_visit_timestamp,json=lastVisitTimestamp,proto3" json:"last_visit_timestamp,omitempty"`
+}
+
+func (x *User) Reset() {
+	*x = User{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *User) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*User) ProtoMessage() {}
+
+func (x *User) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use User.ProtoReflect.Descriptor instead.
+func (*User) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *User) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *User) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *User) GetEmail() string {
+	if x != nil {
+		return x.Email
+	}
+	return ""
+}
+
+func (x *User) GetAvailabilityMessage() string {
+	if x != nil {
+		return x.AvailabilityMessage
+	}
+	return ""
+}
+
+func (x *User) GetLastVisitTimestamp() int32 {
+	if x != nil {
+		return x.LastVisitTimestamp
+	}
+	return 0
+}
+
+// UserSettings represents preferences and account settings of a User.
+// Next available tag: 8
+type UserSettings struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the user that has these settings.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The global site role for the user.
+	SiteRole UserSettings_SiteRole `protobuf:"varint,2,opt,name=site_role,json=siteRole,proto3,enum=monorail.v3.UserSettings_SiteRole" json:"site_role,omitempty"`
+	// Resource name of linked secondary users.
+	LinkedSecondaryUsers []string `protobuf:"bytes,3,rep,name=linked_secondary_users,json=linkedSecondaryUsers,proto3" json:"linked_secondary_users,omitempty"`
+	// The user's access to the site.
+	SiteAccess *UserSettings_SiteAccess `protobuf:"bytes,4,opt,name=site_access,json=siteAccess,proto3" json:"site_access,omitempty"`
+	// Notification trait preferences of the user.
+	NotificationTraits []UserSettings_NotificationTraits `protobuf:"varint,5,rep,packed,name=notification_traits,json=notificationTraits,proto3,enum=monorail.v3.UserSettings_NotificationTraits" json:"notification_traits,omitempty"`
+	// Privacy trait preferences of the user.
+	PrivacyTraits []UserSettings_PrivacyTraits `protobuf:"varint,6,rep,packed,name=privacy_traits,json=privacyTraits,proto3,enum=monorail.v3.UserSettings_PrivacyTraits" json:"privacy_traits,omitempty"`
+	// Site interaction trait preferences of the user.
+	SiteInteractionTraits []UserSettings_SiteInteractionTraits `protobuf:"varint,7,rep,packed,name=site_interaction_traits,json=siteInteractionTraits,proto3,enum=monorail.v3.UserSettings_SiteInteractionTraits" json:"site_interaction_traits,omitempty"`
+}
+
+func (x *UserSettings) Reset() {
+	*x = UserSettings{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UserSettings) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserSettings) ProtoMessage() {}
+
+func (x *UserSettings) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserSettings.ProtoReflect.Descriptor instead.
+func (*UserSettings) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *UserSettings) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *UserSettings) GetSiteRole() UserSettings_SiteRole {
+	if x != nil {
+		return x.SiteRole
+	}
+	return UserSettings_SITE_ROLE_UNSPECIFIED
+}
+
+func (x *UserSettings) GetLinkedSecondaryUsers() []string {
+	if x != nil {
+		return x.LinkedSecondaryUsers
+	}
+	return nil
+}
+
+func (x *UserSettings) GetSiteAccess() *UserSettings_SiteAccess {
+	if x != nil {
+		return x.SiteAccess
+	}
+	return nil
+}
+
+func (x *UserSettings) GetNotificationTraits() []UserSettings_NotificationTraits {
+	if x != nil {
+		return x.NotificationTraits
+	}
+	return nil
+}
+
+func (x *UserSettings) GetPrivacyTraits() []UserSettings_PrivacyTraits {
+	if x != nil {
+		return x.PrivacyTraits
+	}
+	return nil
+}
+
+func (x *UserSettings) GetSiteInteractionTraits() []UserSettings_SiteInteractionTraits {
+	if x != nil {
+		return x.SiteInteractionTraits
+	}
+	return nil
+}
+
+// Defines saved queries that belong to a user.
+//
+// Next available tag: 6
+type UserSavedQuery struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of this saved query.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Display name of this saved query, ie 'open issues'.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// Search term of this saved query.
+	Query string `protobuf:"bytes,3,opt,name=query,proto3" json:"query,omitempty"`
+	// List of projects this query can be searched in.
+	Projects         []string                        `protobuf:"bytes,4,rep,name=projects,proto3" json:"projects,omitempty"`
+	SubscriptionMode UserSavedQuery_SubscriptionMode `protobuf:"varint,5,opt,name=subscription_mode,json=subscriptionMode,proto3,enum=monorail.v3.UserSavedQuery_SubscriptionMode" json:"subscription_mode,omitempty"`
+}
+
+func (x *UserSavedQuery) Reset() {
+	*x = UserSavedQuery{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UserSavedQuery) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserSavedQuery) ProtoMessage() {}
+
+func (x *UserSavedQuery) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserSavedQuery.ProtoReflect.Descriptor instead.
+func (*UserSavedQuery) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *UserSavedQuery) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *UserSavedQuery) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *UserSavedQuery) GetQuery() string {
+	if x != nil {
+		return x.Query
+	}
+	return ""
+}
+
+func (x *UserSavedQuery) GetProjects() []string {
+	if x != nil {
+		return x.Projects
+	}
+	return nil
+}
+
+func (x *UserSavedQuery) GetSubscriptionMode() UserSavedQuery_SubscriptionMode {
+	if x != nil {
+		return x.SubscriptionMode
+	}
+	return UserSavedQuery_SUBSCRIPTION_MODE_UNSPECIFIED
+}
+
+// A project starred by a user.
+//
+// Next available tag: 2
+type ProjectStar struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the ProjectStar.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *ProjectStar) Reset() {
+	*x = ProjectStar{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectStar) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectStar) ProtoMessage() {}
+
+func (x *ProjectStar) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectStar.ProtoReflect.Descriptor instead.
+func (*ProjectStar) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ProjectStar) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// The access the user has to the site.
+// Next available tag: 3
+type UserSettings_SiteAccess struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The status of the user's access to the site.
+	Status UserSettings_SiteAccess_Status `protobuf:"varint,1,opt,name=status,proto3,enum=monorail.v3.UserSettings_SiteAccess_Status" json:"status,omitempty"`
+	// An explanation for the value of `status`.
+	Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"`
+}
+
+func (x *UserSettings_SiteAccess) Reset() {
+	*x = UserSettings_SiteAccess{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UserSettings_SiteAccess) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UserSettings_SiteAccess) ProtoMessage() {}
+
+func (x *UserSettings_SiteAccess) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UserSettings_SiteAccess.ProtoReflect.Descriptor instead.
+func (*UserSettings_SiteAccess) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP(), []int{1, 0}
+}
+
+func (x *UserSettings_SiteAccess) GetStatus() UserSettings_SiteAccess_Status {
+	if x != nil {
+		return x.Status
+	}
+	return UserSettings_SiteAccess_STATUS_UNSPECIFIED
+}
+
+func (x *UserSettings_SiteAccess) GetReason() string {
+	if x != nil {
+		return x.Reason
+	}
+	return ""
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDesc = []byte{
+	0x0a, 0x51, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73,
+	0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65,
+	0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xe7, 0x01, 0x0a,
+	0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73,
+	0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x19, 0x0a, 0x05,
+	0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03,
+	0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x31, 0x0a, 0x14, 0x61, 0x76, 0x61, 0x69, 0x6c,
+	0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x79, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x61, 0x76, 0x61, 0x69, 0x6c, 0x61, 0x62, 0x69, 0x6c,
+	0x69, 0x74, 0x79, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x6c, 0x61,
+	0x73, 0x74, 0x5f, 0x76, 0x69, 0x73, 0x69, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x56, 0x69,
+	0x73, 0x69, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x3a, 0x28, 0xea, 0x41,
+	0x25, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x75, 0x73,
+	0x65, 0x72, 0x5f, 0x69, 0x64, 0x7d, 0x22, 0x96, 0x0a, 0x0a, 0x0c, 0x55, 0x73, 0x65, 0x72, 0x53,
+	0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x33, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1f, 0xfa, 0x41, 0x1c, 0x0a, 0x1a, 0x61, 0x70, 0x69, 0x2e,
+	0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65,
+	0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x44, 0x0a, 0x09,
+	0x73, 0x69, 0x74, 0x65, 0x5f, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32,
+	0x22, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73,
+	0x65, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x53, 0x69, 0x74, 0x65, 0x52,
+	0x6f, 0x6c, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x08, 0x73, 0x69, 0x74, 0x65, 0x52, 0x6f,
+	0x6c, 0x65, 0x12, 0x50, 0x0a, 0x16, 0x6c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x5f, 0x73, 0x65, 0x63,
+	0x6f, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03,
+	0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62,
+	0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x03, 0x52, 0x14,
+	0x6c, 0x69, 0x6e, 0x6b, 0x65, 0x64, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x61, 0x72, 0x79, 0x55,
+	0x73, 0x65, 0x72, 0x73, 0x12, 0x4a, 0x0a, 0x0b, 0x73, 0x69, 0x74, 0x65, 0x5f, 0x61, 0x63, 0x63,
+	0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x74, 0x74,
+	0x69, 0x6e, 0x67, 0x73, 0x2e, 0x53, 0x69, 0x74, 0x65, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x42,
+	0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x73, 0x69, 0x74, 0x65, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73,
+	0x12, 0x5d, 0x0a, 0x13, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x5f, 0x74, 0x72, 0x61, 0x69, 0x74, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x2c, 0x2e,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72,
+	0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x52, 0x12, 0x6e, 0x6f, 0x74,
+	0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12,
+	0x4e, 0x0a, 0x0e, 0x70, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x5f, 0x74, 0x72, 0x61, 0x69, 0x74,
+	0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0e, 0x32, 0x27, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e,
+	0x67, 0x73, 0x2e, 0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73,
+	0x52, 0x0d, 0x70, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12,
+	0x67, 0x0a, 0x17, 0x73, 0x69, 0x74, 0x65, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x61, 0x63, 0x74,
+	0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x72, 0x61, 0x69, 0x74, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0e,
+	0x32, 0x2f, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55,
+	0x73, 0x65, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x53, 0x69, 0x74, 0x65,
+	0x49, 0x6e, 0x74, 0x65, 0x72, 0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x72, 0x61, 0x69, 0x74,
+	0x73, 0x52, 0x15, 0x73, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x61, 0x63, 0x74, 0x69,
+	0x6f, 0x6e, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x1a, 0xa8, 0x01, 0x0a, 0x0a, 0x53, 0x69, 0x74,
+	0x65, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x43, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75,
+	0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2b, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e,
+	0x67, 0x73, 0x2e, 0x53, 0x69, 0x74, 0x65, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x2e, 0x53, 0x74,
+	0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16, 0x0a, 0x06,
+	0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x65,
+	0x61, 0x73, 0x6f, 0x6e, 0x22, 0x3d, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x16,
+	0x0a, 0x12, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49,
+	0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x46, 0x55, 0x4c, 0x4c, 0x5f, 0x41,
+	0x43, 0x43, 0x45, 0x53, 0x53, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x42, 0x41, 0x4e, 0x4e, 0x45,
+	0x44, 0x10, 0x02, 0x22, 0x3c, 0x0a, 0x08, 0x53, 0x69, 0x74, 0x65, 0x52, 0x6f, 0x6c, 0x65, 0x12,
+	0x19, 0x0a, 0x15, 0x53, 0x49, 0x54, 0x45, 0x5f, 0x52, 0x4f, 0x4c, 0x45, 0x5f, 0x55, 0x4e, 0x53,
+	0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0a, 0x0a, 0x06, 0x4e, 0x4f,
+	0x52, 0x4d, 0x41, 0x4c, 0x10, 0x01, 0x12, 0x09, 0x0a, 0x05, 0x41, 0x44, 0x4d, 0x49, 0x4e, 0x10,
+	0x02, 0x22, 0xea, 0x01, 0x0a, 0x12, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12, 0x23, 0x0a, 0x1f, 0x4e, 0x4f, 0x54, 0x49,
+	0x46, 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x54, 0x52, 0x41, 0x49, 0x54, 0x53, 0x5f,
+	0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x27, 0x0a,
+	0x23, 0x4e, 0x4f, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x4f, 0x4e, 0x5f, 0x4f, 0x57, 0x4e, 0x45, 0x44,
+	0x5f, 0x4f, 0x52, 0x5f, 0x43, 0x43, 0x5f, 0x49, 0x53, 0x53, 0x55, 0x45, 0x5f, 0x43, 0x48, 0x41,
+	0x4e, 0x47, 0x45, 0x53, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x4e, 0x4f, 0x54, 0x49, 0x46, 0x59,
+	0x5f, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x52, 0x45, 0x44, 0x5f, 0x49, 0x53, 0x53, 0x55,
+	0x45, 0x5f, 0x43, 0x48, 0x41, 0x4e, 0x47, 0x45, 0x53, 0x10, 0x02, 0x12, 0x22, 0x0a, 0x1e, 0x4e,
+	0x4f, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x52, 0x52, 0x45, 0x44,
+	0x5f, 0x4e, 0x4f, 0x54, 0x49, 0x46, 0x59, 0x5f, 0x44, 0x41, 0x54, 0x45, 0x53, 0x10, 0x03, 0x12,
+	0x18, 0x0a, 0x14, 0x43, 0x4f, 0x4d, 0x50, 0x41, 0x43, 0x54, 0x5f, 0x53, 0x55, 0x42, 0x4a, 0x45,
+	0x43, 0x54, 0x5f, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x04, 0x12, 0x23, 0x0a, 0x1f, 0x47, 0x4d, 0x41,
+	0x49, 0x4c, 0x5f, 0x49, 0x4e, 0x43, 0x4c, 0x55, 0x44, 0x45, 0x5f, 0x49, 0x53, 0x53, 0x55, 0x45,
+	0x5f, 0x4c, 0x49, 0x4e, 0x4b, 0x5f, 0x42, 0x55, 0x54, 0x54, 0x4f, 0x4e, 0x10, 0x05, 0x22, 0x42,
+	0x0a, 0x0d, 0x50, 0x72, 0x69, 0x76, 0x61, 0x63, 0x79, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12,
+	0x1e, 0x0a, 0x1a, 0x50, 0x52, 0x49, 0x56, 0x41, 0x43, 0x59, 0x5f, 0x54, 0x52, 0x41, 0x49, 0x54,
+	0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12,
+	0x11, 0x0a, 0x0d, 0x4f, 0x42, 0x53, 0x43, 0x55, 0x52, 0x45, 0x5f, 0x45, 0x4d, 0x41, 0x49, 0x4c,
+	0x10, 0x01, 0x22, 0x81, 0x01, 0x0a, 0x15, 0x53, 0x69, 0x74, 0x65, 0x49, 0x6e, 0x74, 0x65, 0x72,
+	0x61, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x72, 0x61, 0x69, 0x74, 0x73, 0x12, 0x27, 0x0a, 0x23,
+	0x53, 0x49, 0x54, 0x45, 0x5f, 0x49, 0x4e, 0x54, 0x45, 0x52, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e,
+	0x5f, 0x54, 0x52, 0x41, 0x49, 0x54, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46,
+	0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x26, 0x0a, 0x22, 0x52, 0x45, 0x50, 0x4f, 0x52, 0x54, 0x5f,
+	0x52, 0x45, 0x53, 0x54, 0x52, 0x49, 0x43, 0x54, 0x5f, 0x56, 0x49, 0x45, 0x57, 0x5f, 0x47, 0x4f,
+	0x4f, 0x47, 0x4c, 0x45, 0x5f, 0x49, 0x53, 0x53, 0x55, 0x45, 0x53, 0x10, 0x01, 0x12, 0x17, 0x0a,
+	0x13, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x5f, 0x49, 0x53, 0x53, 0x55, 0x45, 0x5f, 0x42, 0x41,
+	0x4e, 0x4e, 0x45, 0x52, 0x10, 0x02, 0x3a, 0x37, 0xea, 0x41, 0x34, 0x0a, 0x1a, 0x61, 0x70, 0x69,
+	0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x53,
+	0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x16, 0x75, 0x73, 0x65, 0x72, 0x73, 0x65, 0x74,
+	0x74, 0x69, 0x6e, 0x67, 0x73, 0x2f, 0x7b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x7d, 0x22,
+	0xaa, 0x03, 0x0a, 0x0e, 0x55, 0x73, 0x65, 0x72, 0x53, 0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65,
+	0x72, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61,
+	0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69,
+	0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x71, 0x75, 0x65,
+	0x72, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x71, 0x75, 0x65, 0x72, 0x79, 0x12,
+	0x36, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28,
+	0x09, 0x42, 0x1a, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75,
+	0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x08, 0x70,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x59, 0x0a, 0x11, 0x73, 0x75, 0x62, 0x73, 0x63,
+	0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01,
+	0x28, 0x0e, 0x32, 0x2c, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x55, 0x73, 0x65, 0x72, 0x53, 0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x79, 0x2e,
+	0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x6f, 0x64, 0x65,
+	0x52, 0x10, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x6f,
+	0x64, 0x65, 0x22, 0x66, 0x0a, 0x10, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69,
+	0x6f, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x21, 0x0a, 0x1d, 0x53, 0x55, 0x42, 0x53, 0x43, 0x52,
+	0x49, 0x50, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50,
+	0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, 0x4e, 0x4f, 0x5f,
+	0x4e, 0x4f, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x01, 0x12, 0x1a,
+	0x0a, 0x16, 0x49, 0x4d, 0x4d, 0x45, 0x44, 0x49, 0x41, 0x54, 0x45, 0x5f, 0x4e, 0x4f, 0x54, 0x49,
+	0x46, 0x49, 0x43, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x10, 0x02, 0x3a, 0x50, 0xea, 0x41, 0x4d, 0x0a,
+	0x1c, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55,
+	0x73, 0x65, 0x72, 0x53, 0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x2d, 0x75,
+	0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x7d, 0x2f, 0x73,
+	0x61, 0x76, 0x65, 0x64, 0x51, 0x75, 0x65, 0x72, 0x69, 0x65, 0x73, 0x2f, 0x7b, 0x73, 0x61, 0x76,
+	0x65, 0x64, 0x5f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x5f, 0x69, 0x64, 0x7d, 0x22, 0x6e, 0x0a, 0x0b,
+	0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x3a,
+	0x4b, 0xea, 0x41, 0x48, 0x0a, 0x19, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x12,
+	0x2b, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2f, 0x7b, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x7d,
+	0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x73, 0x2f, 0x7b, 0x70,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x7d, 0x42, 0x40, 0x5a, 0x3e,
+	0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f,
+	0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes = make([]protoimpl.EnumInfo, 6)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_goTypes = []interface{}{
+	(UserSettings_SiteRole)(0),              // 0: monorail.v3.UserSettings.SiteRole
+	(UserSettings_NotificationTraits)(0),    // 1: monorail.v3.UserSettings.NotificationTraits
+	(UserSettings_PrivacyTraits)(0),         // 2: monorail.v3.UserSettings.PrivacyTraits
+	(UserSettings_SiteInteractionTraits)(0), // 3: monorail.v3.UserSettings.SiteInteractionTraits
+	(UserSettings_SiteAccess_Status)(0),     // 4: monorail.v3.UserSettings.SiteAccess.Status
+	(UserSavedQuery_SubscriptionMode)(0),    // 5: monorail.v3.UserSavedQuery.SubscriptionMode
+	(*User)(nil),                            // 6: monorail.v3.User
+	(*UserSettings)(nil),                    // 7: monorail.v3.UserSettings
+	(*UserSavedQuery)(nil),                  // 8: monorail.v3.UserSavedQuery
+	(*ProjectStar)(nil),                     // 9: monorail.v3.ProjectStar
+	(*UserSettings_SiteAccess)(nil),         // 10: monorail.v3.UserSettings.SiteAccess
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_depIdxs = []int32{
+	0,  // 0: monorail.v3.UserSettings.site_role:type_name -> monorail.v3.UserSettings.SiteRole
+	10, // 1: monorail.v3.UserSettings.site_access:type_name -> monorail.v3.UserSettings.SiteAccess
+	1,  // 2: monorail.v3.UserSettings.notification_traits:type_name -> monorail.v3.UserSettings.NotificationTraits
+	2,  // 3: monorail.v3.UserSettings.privacy_traits:type_name -> monorail.v3.UserSettings.PrivacyTraits
+	3,  // 4: monorail.v3.UserSettings.site_interaction_traits:type_name -> monorail.v3.UserSettings.SiteInteractionTraits
+	5,  // 5: monorail.v3.UserSavedQuery.subscription_mode:type_name -> monorail.v3.UserSavedQuery.SubscriptionMode
+	4,  // 6: monorail.v3.UserSettings.SiteAccess.status:type_name -> monorail.v3.UserSettings.SiteAccess.Status
+	7,  // [7:7] is the sub-list for method output_type
+	7,  // [7:7] is the sub-list for method input_type
+	7,  // [7:7] is the sub-list for extension type_name
+	7,  // [7:7] is the sub-list for extension extendee
+	0,  // [0:7] is the sub-list for field type_name
+}
+
+func init() {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_init()
+}
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*User); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UserSettings); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UserSavedQuery); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectStar); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UserSettings_SiteAccess); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDesc,
+			NumEnums:      6,
+			NumMessages:   5,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_depIdxs,
+		EnumInfos:         file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_enumTypes,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_depIdxs = nil
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/user_objects.proto b/analysis/internal/bugs/monorail/api_proto/user_objects.proto
new file mode 100644
index 0000000..6fa8fed
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/user_objects.proto
@@ -0,0 +1,185 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file or at
+// https://developers.google.com/open-source/licenses/bsd
+
+// This file defines protobufs for users and related business
+// objects, e.g., users, user preferences.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/api/resource.proto";
+import "google/api/field_behavior.proto";
+
+// User represents a user of the Monorail site.
+// Next available tag: 5
+message User {
+  option (google.api.resource) = {
+    type: "api.crbug.com/User"
+    pattern: "users/{user_id}"
+  };
+  // Resource name of the user.
+  // The API will always return User names with format: users/<user_id>.
+  // However the API will accept User names with formats: users/<user_id> or users/<email>.
+  // To fetch the display_name for any users/<user_id> returned by the API,
+  // you can call {Batch}GetUser{s}.
+  // We represent deleted users within Monorail with `users/1` or `users/2103649657`.
+  string name = 1;
+  // User display_name to show other users using the site.
+  // By default this is the obscured or un-obscured email.
+  string display_name = 2;
+  // Obscured or un-obscured user email or empty if this represents
+  // a deleted user.
+  string email = 4 [ (google.api.field_behavior) = OUTPUT_ONLY ];
+  // User-written indication of their availability or working hours.
+  string availability_message = 3;
+  // Timestamp of the user's last visit
+  int32 last_visit_timestamp = 5;
+}
+
+
+// UserSettings represents preferences and account settings of a User.
+// Next available tag: 8
+message UserSettings {
+  option (google.api.resource) = {
+    type: "api.crbug.com/UserSettings"
+    pattern: "usersettings/{user_id}"
+  };
+
+  // Potential roles of a user.
+  // Next available tag: 3
+  enum SiteRole {
+    // Default value. This value is unused.
+    SITE_ROLE_UNSPECIFIED = 0;
+    // Normal site user with no special site-wide extra permissions.
+    NORMAL = 1;
+    // Site-wide admin role.
+    ADMIN = 2;
+  }
+
+  // The access the user has to the site.
+  // Next available tag: 3
+  message SiteAccess {
+    // Potential status of a user's access to the site.
+    // Next available tag: 3
+    enum Status {
+      // Default value. This value is unused.
+      STATUS_UNSPECIFIED = 0;
+      // The user has access to the site.
+      FULL_ACCESS = 1;
+      // The user is banned from the site.
+      BANNED = 2;
+    }
+
+    // The status of the user's access to the site.
+    Status status = 1;
+    // An explanation for the value of `status`.
+    string reason = 2;
+  }
+
+  // Trait options for notifications the user receives.
+  // Next available tag: 6;
+  enum NotificationTraits {
+    // Default value. This value is unused.
+    NOTIFICATION_TRAITS_UNSPECIFIED = 0;
+    // Send change notifications for issues where user is owner or cc.
+    NOTIFY_ON_OWNED_OR_CC_ISSUE_CHANGES = 1;
+    // Send change notifications for issues the user has starred.
+    NOTIFY_ON_STARRED_ISSUE_CHANGES = 2;
+    // Send date-type field notifications for issues the user has starred.
+    // See monorail/doc/userguide/email.md#why-did-i-get-a-follow_up-email-notification.
+    NOTIFY_ON_STARRED_NOTIFY_DATES = 3;
+    // Email subject lines should be compact.
+    COMPACT_SUBJECT_LINE = 4;
+    // Include a button link to the issue, in Gmail.
+    GMAIL_INCLUDE_ISSUE_LINK_BUTTON = 5;
+  }
+
+  // Privacy trait options for the user.
+  // Next available tag: 2
+  enum PrivacyTraits {
+    // Default value. This value is unused.
+    PRIVACY_TRAITS_UNSPECIFIED = 0;
+    // Obscure the user's email from non-project members throughout the site.
+    OBSCURE_EMAIL = 1;
+  }
+
+  // Site interaction trait options for the user.
+  // Next available tag: 3
+  enum SiteInteractionTraits {
+    // Default value. This value is unused.
+    SITE_INTERACTION_TRAITS_UNSPECIFIED = 0;
+    // Add 'Restrict-View-Google' labels to new issues the user reports.
+    // Issues will only be visible to the user (issue reporter)
+    // and users with the `Google` permission.
+    REPORT_RESTRICT_VIEW_GOOGLE_ISSUES = 1;
+    // When viewing public issues, show a banner to remind the user not
+    // to post sensitive information.
+    PUBLIC_ISSUE_BANNER = 2;
+  }
+
+  // Resource name of the user that has these settings.
+  string name = 1 [ (google.api.resource_reference) = {type: "api.crbug.com/UserSettings"} ];
+  // The global site role for the user.
+  SiteRole site_role = 2 [ (google.api.field_behavior) = OUTPUT_ONLY ];
+  // Resource name of linked secondary users.
+  repeated string linked_secondary_users = 3 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"},
+      (google.api.field_behavior) = OUTPUT_ONLY ];
+  // The user's access to the site.
+  SiteAccess site_access = 4 [ (google.api.field_behavior) = OUTPUT_ONLY ];
+  // Notification trait preferences of the user.
+  repeated NotificationTraits notification_traits = 5;
+  // Privacy trait preferences of the user.
+  repeated PrivacyTraits privacy_traits = 6;
+  // Site interaction trait preferences of the user.
+  repeated SiteInteractionTraits site_interaction_traits = 7;
+}
+
+// Defines saved queries that belong to a user.
+//
+// Next available tag: 6
+message UserSavedQuery {
+  option (google.api.resource) = {
+    type: "api.crbug.com/UserSavedQuery"
+    pattern: "users/{user_id}/savedQueries/{saved_query_id}"
+  };
+
+  // Resource name of this saved query.
+  string name = 1;
+  // Display name of this saved query, ie 'open issues'.
+  string display_name = 2;
+  // Search term of this saved query.
+  string query = 3;
+  // List of projects this query can be searched in.
+  repeated string projects = 4 [
+      (google.api.resource_reference) = { type: "api.crbug.com/Project" }
+  ];
+  // Subscription mode of this saved query
+  // Next available tag: 3
+  enum SubscriptionMode {
+    // Default API value. This value is unused.
+    SUBSCRIPTION_MODE_UNSPECIFIED = 0;
+    // Do not subscribe to notifications.
+    NO_NOTIFICATION = 1;
+    // Subscribe to notifications.
+    IMMEDIATE_NOTIFICATION = 2;
+  }
+  SubscriptionMode subscription_mode = 5;
+}
+
+// A project starred by a user.
+//
+// Next available tag: 2
+message ProjectStar {
+  option (google.api.resource) = {
+    type: "api.crbug.com/ProjectStar"
+    pattern: "users/{user_id}/projectStars/{project_name}"
+  };
+  // Resource name of the ProjectStar.
+  string name = 1;
+}
\ No newline at end of file
diff --git a/analysis/internal/bugs/monorail/api_proto/users.pb.go b/analysis/internal/bugs/monorail/api_proto/users.pb.go
new file mode 100644
index 0000000..706554a
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/users.pb.go
@@ -0,0 +1,1155 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/users.proto
+
+package api_proto
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+	fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// The request message for Users.GetUser.
+// Next available tag: 2
+type GetUserRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the user to request.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetUserRequest) Reset() {
+	*x = GetUserRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetUserRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetUserRequest) ProtoMessage() {}
+
+func (x *GetUserRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetUserRequest.ProtoReflect.Descriptor instead.
+func (*GetUserRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GetUserRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// The request message for Users.BatchGetUsers.
+// Next available tag: 2
+type BatchGetUsersRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the users to request. At most 100 may be requested.
+	Names []string `protobuf:"bytes,1,rep,name=names,proto3" json:"names,omitempty"`
+}
+
+func (x *BatchGetUsersRequest) Reset() {
+	*x = BatchGetUsersRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetUsersRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetUsersRequest) ProtoMessage() {}
+
+func (x *BatchGetUsersRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetUsersRequest.ProtoReflect.Descriptor instead.
+func (*BatchGetUsersRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *BatchGetUsersRequest) GetNames() []string {
+	if x != nil {
+		return x.Names
+	}
+	return nil
+}
+
+// The response message for Users.BatchGetUsers.
+// Next available tag: 2
+type BatchGetUsersResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The users that were requested.
+	Users []*User `protobuf:"bytes,1,rep,name=users,proto3" json:"users,omitempty"`
+}
+
+func (x *BatchGetUsersResponse) Reset() {
+	*x = BatchGetUsersResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetUsersResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetUsersResponse) ProtoMessage() {}
+
+func (x *BatchGetUsersResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetUsersResponse.ProtoReflect.Descriptor instead.
+func (*BatchGetUsersResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *BatchGetUsersResponse) GetUsers() []*User {
+	if x != nil {
+		return x.Users
+	}
+	return nil
+}
+
+// The request message for Users.UpdateUser.
+// Next available tag: 3
+type UpdateUserRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The user's `name` field is used to identify the user to be updated.
+	User *User `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"`
+	// The list of fields to be updated.
+	UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"`
+}
+
+func (x *UpdateUserRequest) Reset() {
+	*x = UpdateUserRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UpdateUserRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateUserRequest) ProtoMessage() {}
+
+func (x *UpdateUserRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateUserRequest.ProtoReflect.Descriptor instead.
+func (*UpdateUserRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *UpdateUserRequest) GetUser() *User {
+	if x != nil {
+		return x.User
+	}
+	return nil
+}
+
+func (x *UpdateUserRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
+	if x != nil {
+		return x.UpdateMask
+	}
+	return nil
+}
+
+// The request message for Users.StarProject.
+// Next available tag: 2
+type StarProjectRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name for the Project to star.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+}
+
+func (x *StarProjectRequest) Reset() {
+	*x = StarProjectRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *StarProjectRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StarProjectRequest) ProtoMessage() {}
+
+func (x *StarProjectRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use StarProjectRequest.ProtoReflect.Descriptor instead.
+func (*StarProjectRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *StarProjectRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+// The request message for Users.UnStarProject.
+// Next available tag: 2
+type UnStarProjectRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name for the Project to unstar.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+}
+
+func (x *UnStarProjectRequest) Reset() {
+	*x = UnStarProjectRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UnStarProjectRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UnStarProjectRequest) ProtoMessage() {}
+
+func (x *UnStarProjectRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UnStarProjectRequest.ProtoReflect.Descriptor instead.
+func (*UnStarProjectRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *UnStarProjectRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+// The request message for Users.ListProjectStars.
+// Next available tag: 4
+type ListProjectStarsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name for the user having stars listed.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The maximum number of items to return. The service may return fewer than
+	// this value.
+	// If unspecified, at most 1000 items will be returned.
+	PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `ListProjectStars` call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to `ListProjectStars` must
+	// match the call that provided the page token.
+	PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *ListProjectStarsRequest) Reset() {
+	*x = ListProjectStarsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListProjectStarsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListProjectStarsRequest) ProtoMessage() {}
+
+func (x *ListProjectStarsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListProjectStarsRequest.ProtoReflect.Descriptor instead.
+func (*ListProjectStarsRequest) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ListProjectStarsRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *ListProjectStarsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *ListProjectStarsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// The response message for Users.ListProjectStars.
+// Next available tag: 3
+type ListProjectStarsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Data for each starred project.
+	ProjectStars []*ProjectStar `protobuf:"bytes,1,rep,name=project_stars,json=projectStars,proto3" json:"project_stars,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there are no subsequent pages.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *ListProjectStarsResponse) Reset() {
+	*x = ListProjectStarsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListProjectStarsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListProjectStarsResponse) ProtoMessage() {}
+
+func (x *ListProjectStarsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListProjectStarsResponse.ProtoReflect.Descriptor instead.
+func (*ListProjectStarsResponse) Descriptor() ([]byte, []int) {
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *ListProjectStarsResponse) GetProjectStars() []*ProjectStar {
+	if x != nil {
+		return x.ProjectStars
+	}
+	return nil
+}
+
+func (x *ListProjectStarsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+var File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto protoreflect.FileDescriptor
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDesc = []byte{
+	0x0a, 0x4a, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d, 0x2e, 0x6f, 0x72,
+	0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73, 0x2f, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x75, 0x73, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0b, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+	0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61,
+	0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x19, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x51, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69,
+	0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c,
+	0x79, 0x73, 0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75,
+	0x67, 0x73, 0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x6f, 0x62, 0x6a, 0x65, 0x63,
+	0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x20, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73,
+	0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x40, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x55, 0x73,
+	0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x2e, 0x0a, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70,
+	0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72,
+	0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x48, 0x0a, 0x14, 0x42, 0x61, 0x74,
+	0x63, 0x68, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x12, 0x30, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09,
+	0x42, 0x1a, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67,
+	0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x02, 0x52, 0x05, 0x6e, 0x61,
+	0x6d, 0x65, 0x73, 0x22, 0x40, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x55,
+	0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x27, 0x0a, 0x05,
+	0x75, 0x73, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x05,
+	0x75, 0x73, 0x65, 0x72, 0x73, 0x22, 0x98, 0x01, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65,
+	0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x41, 0x0a, 0x04, 0x75,
+	0x73, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x11, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f,
+	0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x42, 0x1a, 0xe0, 0x41,
+	0x02, 0xfa, 0x41, 0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e,
+	0x63, 0x6f, 0x6d, 0x2f, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75, 0x73, 0x65, 0x72, 0x12, 0x40,
+	0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x42,
+	0x03, 0xe0, 0x41, 0x02, 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x73, 0x6b,
+	0x22, 0x4d, 0x0a, 0x12, 0x53, 0x74, 0x61, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61, 0x70,
+	0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x22,
+	0x4f, 0x0a, 0x14, 0x55, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1d, 0xfa, 0x41, 0x17, 0x0a, 0x15, 0x61,
+	0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x50, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x22, 0x89, 0x01, 0x0a, 0x17, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x53, 0x74, 0x61, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x06,
+	0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1a, 0xfa, 0x41,
+	0x14, 0x0a, 0x12, 0x61, 0x70, 0x69, 0x2e, 0x63, 0x72, 0x62, 0x75, 0x67, 0x2e, 0x63, 0x6f, 0x6d,
+	0x2f, 0x55, 0x73, 0x65, 0x72, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74,
+	0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a,
+	0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x81, 0x01, 0x0a,
+	0x18, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72,
+	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3d, 0x0a, 0x0d, 0x70, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x5f, 0x73, 0x74, 0x61, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b,
+	0x32, 0x18, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x50,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x52, 0x0c, 0x70, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74,
+	0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e,
+	0x32, 0xde, 0x03, 0x0a, 0x05, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x3b, 0x0a, 0x07, 0x47, 0x65,
+	0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x1b, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x2e, 0x76, 0x33, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x1a, 0x11, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x55, 0x73, 0x65, 0x72, 0x22, 0x00, 0x12, 0x58, 0x0a, 0x0d, 0x42, 0x61, 0x74, 0x63, 0x68,
+	0x47, 0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x12, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x55,
+	0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47,
+	0x65, 0x74, 0x55, 0x73, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x12, 0x41, 0x0a, 0x0a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12,
+	0x1e, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x70,
+	0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a,
+	0x11, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x55, 0x73,
+	0x65, 0x72, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x0b, 0x53, 0x74, 0x61, 0x72, 0x50, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x12, 0x1f, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76,
+	0x33, 0x2e, 0x53, 0x74, 0x61, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e,
+	0x76, 0x33, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x22, 0x00,
+	0x12, 0x4c, 0x0a, 0x0d, 0x55, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x12, 0x21, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e,
+	0x55, 0x6e, 0x53, 0x74, 0x61, 0x72, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x61,
+	0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61,
+	0x72, 0x73, 0x12, 0x24, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33,
+	0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x53, 0x74, 0x61, 0x72,
+	0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x2e, 0x76, 0x33, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x53, 0x74, 0x61, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22,
+	0x00, 0x42, 0x40, 0x5a, 0x3e, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69, 0x75, 0x6d,
+	0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73,
+	0x69, 0x73, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x62, 0x75, 0x67, 0x73,
+	0x2f, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x2f, 0x61, 0x70, 0x69, 0x5f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescOnce sync.Once
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescData = file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDesc
+)
+
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescGZIP() []byte {
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescOnce.Do(func() {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescData = protoimpl.X.CompressGZIP(file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescData)
+	})
+	return file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDescData
+}
+
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_goTypes = []interface{}{
+	(*GetUserRequest)(nil),           // 0: monorail.v3.GetUserRequest
+	(*BatchGetUsersRequest)(nil),     // 1: monorail.v3.BatchGetUsersRequest
+	(*BatchGetUsersResponse)(nil),    // 2: monorail.v3.BatchGetUsersResponse
+	(*UpdateUserRequest)(nil),        // 3: monorail.v3.UpdateUserRequest
+	(*StarProjectRequest)(nil),       // 4: monorail.v3.StarProjectRequest
+	(*UnStarProjectRequest)(nil),     // 5: monorail.v3.UnStarProjectRequest
+	(*ListProjectStarsRequest)(nil),  // 6: monorail.v3.ListProjectStarsRequest
+	(*ListProjectStarsResponse)(nil), // 7: monorail.v3.ListProjectStarsResponse
+	(*User)(nil),                     // 8: monorail.v3.User
+	(*fieldmaskpb.FieldMask)(nil),    // 9: google.protobuf.FieldMask
+	(*ProjectStar)(nil),              // 10: monorail.v3.ProjectStar
+	(*emptypb.Empty)(nil),            // 11: google.protobuf.Empty
+}
+var file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_depIdxs = []int32{
+	8,  // 0: monorail.v3.BatchGetUsersResponse.users:type_name -> monorail.v3.User
+	8,  // 1: monorail.v3.UpdateUserRequest.user:type_name -> monorail.v3.User
+	9,  // 2: monorail.v3.UpdateUserRequest.update_mask:type_name -> google.protobuf.FieldMask
+	10, // 3: monorail.v3.ListProjectStarsResponse.project_stars:type_name -> monorail.v3.ProjectStar
+	0,  // 4: monorail.v3.Users.GetUser:input_type -> monorail.v3.GetUserRequest
+	1,  // 5: monorail.v3.Users.BatchGetUsers:input_type -> monorail.v3.BatchGetUsersRequest
+	3,  // 6: monorail.v3.Users.UpdateUser:input_type -> monorail.v3.UpdateUserRequest
+	4,  // 7: monorail.v3.Users.StarProject:input_type -> monorail.v3.StarProjectRequest
+	5,  // 8: monorail.v3.Users.UnStarProject:input_type -> monorail.v3.UnStarProjectRequest
+	6,  // 9: monorail.v3.Users.ListProjectStars:input_type -> monorail.v3.ListProjectStarsRequest
+	8,  // 10: monorail.v3.Users.GetUser:output_type -> monorail.v3.User
+	2,  // 11: monorail.v3.Users.BatchGetUsers:output_type -> monorail.v3.BatchGetUsersResponse
+	8,  // 12: monorail.v3.Users.UpdateUser:output_type -> monorail.v3.User
+	10, // 13: monorail.v3.Users.StarProject:output_type -> monorail.v3.ProjectStar
+	11, // 14: monorail.v3.Users.UnStarProject:output_type -> google.protobuf.Empty
+	7,  // 15: monorail.v3.Users.ListProjectStars:output_type -> monorail.v3.ListProjectStarsResponse
+	10, // [10:16] is the sub-list for method output_type
+	4,  // [4:10] is the sub-list for method input_type
+	4,  // [4:4] is the sub-list for extension type_name
+	4,  // [4:4] is the sub-list for extension extendee
+	0,  // [0:4] is the sub-list for field type_name
+}
+
+func init() { file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_init() }
+func file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_init() {
+	if File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto != nil {
+		return
+	}
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_user_objects_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetUserRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetUsersRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetUsersResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UpdateUserRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*StarProjectRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UnStarProjectRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListProjectStarsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListProjectStarsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   8,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_goTypes,
+		DependencyIndexes: file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_depIdxs,
+		MessageInfos:      file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_msgTypes,
+	}.Build()
+	File_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto = out.File
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_rawDesc = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_goTypes = nil
+	file_go_chromium_org_luci_analysis_internal_bugs_monorail_api_proto_users_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// UsersClient is the client API for Users service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type UsersClient interface {
+	// status: ALPHA
+	// Returns the requested User.
+	//
+	// Raises:
+	//   NOT_FOUND is the user is not found.
+	//   INVALID_ARGUMENT if the `name` is invalid.
+	GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error)
+	// status: ALPHA
+	// Returns all of the requested Users.
+	//
+	// Raises:
+	//   NOT_FOUND if any users are not found.
+	//   INVALID_ARGUMENT if any `names` are invalid.
+	BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
+	// status: NOT READY
+	// Updates a User.
+	//
+	// Raises:
+	//   NOT_FOUND if the user is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to update the user.
+	//   INVALID_ARGUMENT if required fields are missing or fields are invalid.
+	UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*User, error)
+	// status: NOT READY
+	// Stars a given project for the requestor.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested project is not found.
+	//   INVALID_ARGUMENT if the given `project` is not valid.
+	StarProject(ctx context.Context, in *StarProjectRequest, opts ...grpc.CallOption) (*ProjectStar, error)
+	// status: NOT READY
+	// Unstars a given project for the requestor.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested project is not found.
+	//   INVALID_ARGUMENT if the given `project` is not valid.
+	UnStarProject(ctx context.Context, in *UnStarProjectRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Lists all of a user's starred projects.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested user is not found.
+	//   INVALID_ARGUMENT if the given `parent` is not valid.
+	ListProjectStars(ctx context.Context, in *ListProjectStarsRequest, opts ...grpc.CallOption) (*ListProjectStarsResponse, error)
+}
+type usersPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewUsersPRPCClient(client *prpc.Client) UsersClient {
+	return &usersPRPCClient{client}
+}
+
+func (c *usersPRPCClient) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) {
+	out := new(User)
+	err := c.client.Call(ctx, "monorail.v3.Users", "GetUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersPRPCClient) BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error) {
+	out := new(BatchGetUsersResponse)
+	err := c.client.Call(ctx, "monorail.v3.Users", "BatchGetUsers", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersPRPCClient) UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*User, error) {
+	out := new(User)
+	err := c.client.Call(ctx, "monorail.v3.Users", "UpdateUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersPRPCClient) StarProject(ctx context.Context, in *StarProjectRequest, opts ...grpc.CallOption) (*ProjectStar, error) {
+	out := new(ProjectStar)
+	err := c.client.Call(ctx, "monorail.v3.Users", "StarProject", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersPRPCClient) UnStarProject(ctx context.Context, in *UnStarProjectRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.client.Call(ctx, "monorail.v3.Users", "UnStarProject", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersPRPCClient) ListProjectStars(ctx context.Context, in *ListProjectStarsRequest, opts ...grpc.CallOption) (*ListProjectStarsResponse, error) {
+	out := new(ListProjectStarsResponse)
+	err := c.client.Call(ctx, "monorail.v3.Users", "ListProjectStars", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type usersClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewUsersClient(cc grpc.ClientConnInterface) UsersClient {
+	return &usersClient{cc}
+}
+
+func (c *usersClient) GetUser(ctx context.Context, in *GetUserRequest, opts ...grpc.CallOption) (*User, error) {
+	out := new(User)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Users/GetUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersClient) BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error) {
+	out := new(BatchGetUsersResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Users/BatchGetUsers", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersClient) UpdateUser(ctx context.Context, in *UpdateUserRequest, opts ...grpc.CallOption) (*User, error) {
+	out := new(User)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Users/UpdateUser", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersClient) StarProject(ctx context.Context, in *StarProjectRequest, opts ...grpc.CallOption) (*ProjectStar, error) {
+	out := new(ProjectStar)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Users/StarProject", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersClient) UnStarProject(ctx context.Context, in *UnStarProjectRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	out := new(emptypb.Empty)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Users/UnStarProject", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *usersClient) ListProjectStars(ctx context.Context, in *ListProjectStarsRequest, opts ...grpc.CallOption) (*ListProjectStarsResponse, error) {
+	out := new(ListProjectStarsResponse)
+	err := c.cc.Invoke(ctx, "/monorail.v3.Users/ListProjectStars", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// UsersServer is the server API for Users service.
+type UsersServer interface {
+	// status: ALPHA
+	// Returns the requested User.
+	//
+	// Raises:
+	//   NOT_FOUND is the user is not found.
+	//   INVALID_ARGUMENT if the `name` is invalid.
+	GetUser(context.Context, *GetUserRequest) (*User, error)
+	// status: ALPHA
+	// Returns all of the requested Users.
+	//
+	// Raises:
+	//   NOT_FOUND if any users are not found.
+	//   INVALID_ARGUMENT if any `names` are invalid.
+	BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
+	// status: NOT READY
+	// Updates a User.
+	//
+	// Raises:
+	//   NOT_FOUND if the user is not found.
+	//   PERMISSION_DENIED if the requester is not allowed to update the user.
+	//   INVALID_ARGUMENT if required fields are missing or fields are invalid.
+	UpdateUser(context.Context, *UpdateUserRequest) (*User, error)
+	// status: NOT READY
+	// Stars a given project for the requestor.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested project is not found.
+	//   INVALID_ARGUMENT if the given `project` is not valid.
+	StarProject(context.Context, *StarProjectRequest) (*ProjectStar, error)
+	// status: NOT READY
+	// Unstars a given project for the requestor.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested project is not found.
+	//   INVALID_ARGUMENT if the given `project` is not valid.
+	UnStarProject(context.Context, *UnStarProjectRequest) (*emptypb.Empty, error)
+	// status: NOT READY
+	// Lists all of a user's starred projects.
+	//
+	// Raises:
+	//   NOT_FOUND if the requested user is not found.
+	//   INVALID_ARGUMENT if the given `parent` is not valid.
+	ListProjectStars(context.Context, *ListProjectStarsRequest) (*ListProjectStarsResponse, error)
+}
+
+// UnimplementedUsersServer can be embedded to have forward compatible implementations.
+type UnimplementedUsersServer struct {
+}
+
+func (*UnimplementedUsersServer) GetUser(context.Context, *GetUserRequest) (*User, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetUser not implemented")
+}
+func (*UnimplementedUsersServer) BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method BatchGetUsers not implemented")
+}
+func (*UnimplementedUsersServer) UpdateUser(context.Context, *UpdateUserRequest) (*User, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UpdateUser not implemented")
+}
+func (*UnimplementedUsersServer) StarProject(context.Context, *StarProjectRequest) (*ProjectStar, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method StarProject not implemented")
+}
+func (*UnimplementedUsersServer) UnStarProject(context.Context, *UnStarProjectRequest) (*emptypb.Empty, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method UnStarProject not implemented")
+}
+func (*UnimplementedUsersServer) ListProjectStars(context.Context, *ListProjectStarsRequest) (*ListProjectStarsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method ListProjectStars not implemented")
+}
+
+func RegisterUsersServer(s prpc.Registrar, srv UsersServer) {
+	s.RegisterService(&_Users_serviceDesc, srv)
+}
+
+func _Users_GetUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetUserRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(UsersServer).GetUser(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Users/GetUser",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(UsersServer).GetUser(ctx, req.(*GetUserRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Users_BatchGetUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(BatchGetUsersRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(UsersServer).BatchGetUsers(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Users/BatchGetUsers",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(UsersServer).BatchGetUsers(ctx, req.(*BatchGetUsersRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Users_UpdateUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UpdateUserRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(UsersServer).UpdateUser(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Users/UpdateUser",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(UsersServer).UpdateUser(ctx, req.(*UpdateUserRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Users_StarProject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(StarProjectRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(UsersServer).StarProject(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Users/StarProject",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(UsersServer).StarProject(ctx, req.(*StarProjectRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Users_UnStarProject_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UnStarProjectRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(UsersServer).UnStarProject(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Users/UnStarProject",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(UsersServer).UnStarProject(ctx, req.(*UnStarProjectRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Users_ListProjectStars_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListProjectStarsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(UsersServer).ListProjectStars(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/monorail.v3.Users/ListProjectStars",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(UsersServer).ListProjectStars(ctx, req.(*ListProjectStarsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Users_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "monorail.v3.Users",
+	HandlerType: (*UsersServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetUser",
+			Handler:    _Users_GetUser_Handler,
+		},
+		{
+			MethodName: "BatchGetUsers",
+			Handler:    _Users_BatchGetUsers_Handler,
+		},
+		{
+			MethodName: "UpdateUser",
+			Handler:    _Users_UpdateUser_Handler,
+		},
+		{
+			MethodName: "StarProject",
+			Handler:    _Users_StarProject_Handler,
+		},
+		{
+			MethodName: "UnStarProject",
+			Handler:    _Users_UnStarProject_Handler,
+		},
+		{
+			MethodName: "ListProjectStars",
+			Handler:    _Users_ListProjectStars_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/users.proto",
+}
diff --git a/analysis/internal/bugs/monorail/api_proto/users.proto b/analysis/internal/bugs/monorail/api_proto/users.proto
new file mode 100644
index 0000000..8f43d17
--- /dev/null
+++ b/analysis/internal/bugs/monorail/api_proto/users.proto
@@ -0,0 +1,161 @@
+// Copyright 2020 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+syntax = "proto3";
+
+package monorail.v3;
+
+option go_package = "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto";
+
+import "google/api/field_behavior.proto";
+import "google/api/resource.proto";
+import "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto/user_objects.proto";
+import "google/protobuf/empty.proto";
+import "google/protobuf/field_mask.proto";
+
+// ***ONLY CALL rpcs WITH `status: {ALPHA|STABLE}`***
+// rpcs without `status` are not ready.
+
+// Users service includes all methods needed for managing Users.
+service Users {
+  // status: ALPHA
+  // Returns the requested User.
+  //
+  // Raises:
+  //   NOT_FOUND is the user is not found.
+  //   INVALID_ARGUMENT if the `name` is invalid.
+  rpc GetUser (GetUserRequest) returns (User) {}
+
+  // status: ALPHA
+  // Returns all of the requested Users.
+  //
+  // Raises:
+  //   NOT_FOUND if any users are not found.
+  //   INVALID_ARGUMENT if any `names` are invalid.
+  rpc BatchGetUsers (BatchGetUsersRequest) returns (BatchGetUsersResponse) {}
+
+  // status: NOT READY
+  // Updates a User.
+  //
+  // Raises:
+  //   NOT_FOUND if the user is not found.
+  //   PERMISSION_DENIED if the requester is not allowed to update the user.
+  //   INVALID_ARGUMENT if required fields are missing or fields are invalid.
+  rpc UpdateUser (UpdateUserRequest) returns (User) {}
+
+  // status: NOT READY
+  // Stars a given project for the requestor.
+  //
+  // Raises:
+  //   NOT_FOUND if the requested project is not found.
+  //   INVALID_ARGUMENT if the given `project` is not valid.
+  rpc StarProject (StarProjectRequest) returns (ProjectStar) {}
+
+  // status: NOT READY
+  // Unstars a given project for the requestor.
+  //
+  // Raises:
+  //   NOT_FOUND if the requested project is not found.
+  //   INVALID_ARGUMENT if the given `project` is not valid.
+  rpc UnStarProject (UnStarProjectRequest) returns (google.protobuf.Empty) {}
+
+  // status: NOT READY
+  // Lists all of a user's starred projects.
+  //
+  // Raises:
+  //   NOT_FOUND if the requested user is not found.
+  //   INVALID_ARGUMENT if the given `parent` is not valid.
+  rpc ListProjectStars (ListProjectStarsRequest) returns (ListProjectStarsResponse) {}
+}
+
+
+// The request message for Users.GetUser.
+// Next available tag: 2
+message GetUserRequest {
+  // The name of the user to request.
+  string name = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"},
+      (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// The request message for Users.BatchGetUsers.
+// Next available tag: 2
+message BatchGetUsersRequest {
+  // The name of the users to request. At most 100 may be requested.
+  repeated string names = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"},
+      (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// The response message for Users.BatchGetUsers.
+// Next available tag: 2
+message BatchGetUsersResponse {
+  // The users that were requested.
+  repeated User users = 1;
+}
+
+
+// The request message for Users.UpdateUser.
+// Next available tag: 3
+message UpdateUserRequest {
+  // The user's `name` field is used to identify the user to be updated.
+  User user = 1 [
+      (google.api.field_behavior) = REQUIRED,
+      (google.api.resource_reference) = {type: "api.crbug.com/User"} ];
+  // The list of fields to be updated.
+  google.protobuf.FieldMask update_mask = 2 [ (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// The request message for Users.StarProject.
+// Next available tag: 2
+message StarProjectRequest {
+  // The resource name for the Project to star.
+  string project = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+      (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// The request message for Users.UnStarProject.
+// Next available tag: 2
+message UnStarProjectRequest {
+  // The resource name for the Project to unstar.
+  string project = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/Project"},
+      (google.api.field_behavior) = REQUIRED ];
+}
+
+
+// The request message for Users.ListProjectStars.
+// Next available tag: 4
+message ListProjectStarsRequest {
+  // The resource name for the user having stars listed.
+  string parent = 1 [
+      (google.api.resource_reference) = {type: "api.crbug.com/User"},
+      (google.api.field_behavior) = REQUIRED ];
+  // The maximum number of items to return. The service may return fewer than
+  // this value.
+  // If unspecified, at most 1000 items will be returned.
+  int32 page_size = 2;
+  // A page token, received from a previous `ListProjectStars` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `ListProjectStars` must
+  // match the call that provided the page token.
+  string page_token = 3;
+}
+
+
+// The response message for Users.ListProjectStars.
+// Next available tag: 3
+message ListProjectStarsResponse {
+  // Data for each starred project.
+  repeated ProjectStar project_stars = 1;
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there are no subsequent pages.
+  string next_page_token = 2;
+}
diff --git a/analysis/internal/bugs/monorail/definition.go b/analysis/internal/bugs/monorail/definition.go
new file mode 100644
index 0000000..6e6affc
--- /dev/null
+++ b/analysis/internal/bugs/monorail/definition.go
@@ -0,0 +1,701 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+
+	"google.golang.org/genproto/protobuf/field_mask"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+const (
+	DescriptionTemplate = `%s
+
+This bug has been automatically filed by Weetbix in response to a cluster of test failures.`
+
+	LinkTemplate = `See failure impact and configure the failure association rule for this bug at: %s`
+)
+
+const (
+	manualPriorityLabel = "Weetbix-Manual-Priority"
+	restrictViewLabel   = "Restrict-View-Google"
+	autoFiledLabel      = "Weetbix-Auto-Filed"
+)
+
+// whitespaceRE matches blocks of whitespace, including new lines tabs and
+// spaces.
+var whitespaceRE = regexp.MustCompile(`[ \t\n]+`)
+
+// priorityRE matches chromium monorail priority values.
+var priorityRE = regexp.MustCompile(`^Pri-([0123])$`)
+
+// AutomationUsers are the identifiers of Weetbix automation users in monorail.
+var AutomationUsers = []string{
+	"users/3816576959", // chops-weetbix@appspot.gserviceaccount.com
+	"users/4149141945", // chops-weetbix-dev@appspot.gserviceaccount.com
+}
+
+// ChromiumDefaultAssignee is the default issue assignee for chromium.
+// This should be deleted in future when auto-assignment is implemented.
+const ChromiumDefaultAssignee = "users/2581171748" // mwarton@chromium.org
+
+// VerifiedStatus is that status of bugs that have been fixed and verified.
+const VerifiedStatus = "Verified"
+
+// AssignedStatus is the status of bugs that are open and assigned to an owner.
+const AssignedStatus = "Assigned"
+
+// UntriagedStatus is the status of bugs that have just been opened.
+const UntriagedStatus = "Untriaged"
+
+// DuplicateStatus is the status of bugs which are closed as duplicate.
+const DuplicateStatus = "Duplicate"
+
+// FixedStatus is the status of bugs which have been fixed, but not verified.
+const FixedStatus = "Fixed"
+
+// ClosedStatuses is the status of bugs which are closed.
+// Comprises statuses configured on chromium and fuchsia projects. Ideally
+// this would be configuration, but given the coming monorail deprecation,
+// there is limited value.
+var ClosedStatuses = map[string]struct{}{
+	"Fixed":           {},
+	"Verified":        {},
+	"WontFix":         {},
+	"Done":            {},
+	"NotReproducible": {},
+	"Archived":        {},
+	"Obsolete":        {},
+}
+
+// ArchivedStatuses is the subset of closed statuses that indicate a bug
+// that should no longer be used.
+var ArchivedStatuses = map[string]struct{}{
+	"Archived": {},
+	"Obsolete": {},
+}
+
+// Generator provides access to a methods to generate a new bug and/or bug
+// updates for a cluster.
+type Generator struct {
+	// The GAE app id, e.g. "chops-weetbix".
+	appID string
+	// The monorail configuration to use.
+	monorailCfg *configpb.MonorailProject
+	// The threshold at which bugs are filed. Used here as the threshold
+	// at which to re-open verified bugs.
+	bugFilingThreshold *configpb.ImpactThreshold
+}
+
+// NewGenerator initialises a new Generator.
+func NewGenerator(appID string, projectCfg *configpb.ProjectConfig) (*Generator, error) {
+	if len(projectCfg.Monorail.Priorities) == 0 {
+		return nil, fmt.Errorf("invalid configuration for monorail project %q; no monorail priorities configured", projectCfg.Monorail.Project)
+	}
+	return &Generator{
+		appID:              appID,
+		monorailCfg:        projectCfg.Monorail,
+		bugFilingThreshold: projectCfg.BugFilingThreshold,
+	}, nil
+}
+
+// PrepareNew prepares a new bug from the given cluster. Title and description
+// are the cluster-specific bug title and description.
+func (g *Generator) PrepareNew(impact *bugs.ClusterImpact, description *clustering.ClusterDescription, components []string) *mpb.MakeIssueRequest {
+	issue := &mpb.Issue{
+		Summary: fmt.Sprintf("Tests are failing: %v", sanitiseTitle(description.Title, 150)),
+		State:   mpb.IssueContentState_ACTIVE,
+		Status:  &mpb.Issue_StatusValue{Status: UntriagedStatus},
+		FieldValues: []*mpb.FieldValue{
+			{
+				Field: g.priorityFieldName(),
+				Value: g.clusterPriority(impact),
+			},
+		},
+		Labels: []*mpb.Issue_LabelValue{{
+			Label: restrictViewLabel,
+		}, {
+			Label: autoFiledLabel,
+		}},
+	}
+	for _, fv := range g.monorailCfg.DefaultFieldValues {
+		issue.FieldValues = append(issue.FieldValues, &mpb.FieldValue{
+			Field: fmt.Sprintf("projects/%s/fieldDefs/%v", g.monorailCfg.Project, fv.FieldId),
+			Value: fv.Value,
+		})
+	}
+	for _, c := range components {
+		if !componentRE.MatchString(c) {
+			// Discard syntactically invalid components, test results
+			// cannot be trusted.
+			continue
+		}
+		issue.Components = append(issue.Components, &mpb.Issue_ComponentValue{
+			// E.g. projects/chromium/componentDefs/Blink>Workers.
+			Component: fmt.Sprintf("projects/%s/componentDefs/%s", g.monorailCfg.Project, c),
+		})
+	}
+	if g.monorailCfg.Project == "chromium" {
+		// Assign mwarton@chromium.org in both prod and staging monorail.
+		issue.Owner = &mpb.Issue_UserValue{User: ChromiumDefaultAssignee}
+	}
+
+	return &mpb.MakeIssueRequest{
+		Parent:      fmt.Sprintf("projects/%s", g.monorailCfg.Project),
+		Issue:       issue,
+		Description: fmt.Sprintf(DescriptionTemplate, description.Description),
+		NotifyType:  mpb.NotifyType_EMAIL,
+	}
+}
+
+// linkToRuleComment returns a comment that links the user to the failure
+// association rule in Weetbix. bugName is the internal bug name,
+// e.g. "chromium/100".
+func (g *Generator) linkToRuleComment(bugName string) string {
+	bugLink := fmt.Sprintf("https://%s.appspot.com/b/%s", g.appID, bugName)
+	return fmt.Sprintf(LinkTemplate, bugLink)
+}
+
+// PrepareLinkComment prepares a request that adds links to Weetbix to
+// a monorail bug.
+func (g *Generator) PrepareLinkComment(bugName string) (*mpb.ModifyIssuesRequest, error) {
+	issueName, err := toMonorailIssueName(bugName)
+	if err != nil {
+		return nil, err
+	}
+
+	result := &mpb.ModifyIssuesRequest{
+		Deltas: []*mpb.IssueDelta{
+			{
+				Issue: &mpb.Issue{
+					Name: issueName,
+				},
+				UpdateMask: &field_mask.FieldMask{},
+			},
+		},
+		NotifyType:     mpb.NotifyType_NO_NOTIFICATION,
+		CommentContent: g.linkToRuleComment(bugName),
+	}
+	return result, nil
+}
+
+// MarkAvailable prepares a request that puts the bug in an
+// available state and adds the given message.
+func (g *Generator) MarkAvailable(bugName, message string) (*mpb.ModifyIssuesRequest, error) {
+	name, err := toMonorailIssueName(bugName)
+	if err != nil {
+		return nil, err
+	}
+
+	delta := &mpb.IssueDelta{
+		Issue: &mpb.Issue{
+			Name: name,
+			Status: &mpb.Issue_StatusValue{
+				Status: "Available",
+			},
+		},
+		UpdateMask: &field_mask.FieldMask{
+			Paths: []string{"status"},
+		},
+	}
+	req := &mpb.ModifyIssuesRequest{
+		Deltas: []*mpb.IssueDelta{
+			delta,
+		},
+		NotifyType:     mpb.NotifyType_EMAIL,
+		CommentContent: strings.Join([]string{message, g.linkToRuleComment(bugName)}, "\n\n"),
+	}
+	return req, nil
+}
+
+func (g *Generator) priorityFieldName() string {
+	return fmt.Sprintf("projects/%s/fieldDefs/%v", g.monorailCfg.Project, g.monorailCfg.PriorityFieldId)
+}
+
+// NeedsUpdate determines if the bug for the given cluster needs to be updated.
+func (g *Generator) NeedsUpdate(impact *bugs.ClusterImpact, issue *mpb.Issue) bool {
+	// Bugs must have restrict view label to be updated.
+	if !hasLabel(issue, restrictViewLabel) {
+		return false
+	}
+	// Cases that a bug may be updated follow.
+	switch {
+	case !g.isCompatibleWithVerified(impact, issueVerified(issue)):
+		return true
+	case !hasLabel(issue, manualPriorityLabel) &&
+		!issueVerified(issue) &&
+		!g.isCompatibleWithPriority(impact, g.IssuePriority(issue)):
+		// The priority has changed on a cluster which is not verified as fixed
+		// and the user isn't manually controlling the priority.
+		return true
+	default:
+		return false
+	}
+}
+
+// MakeUpdate prepares an updated for the bug associated with a given cluster.
+// Must ONLY be called if NeedsUpdate(...) returns true.
+func (g *Generator) MakeUpdate(impact *bugs.ClusterImpact, issue *mpb.Issue, comments []*mpb.Comment) *mpb.ModifyIssuesRequest {
+	delta := &mpb.IssueDelta{
+		Issue: &mpb.Issue{
+			Name: issue.Name,
+		},
+		UpdateMask: &field_mask.FieldMask{
+			Paths: []string{},
+		},
+	}
+
+	var commentary []string
+	notify := false
+	issueVerified := issueVerified(issue)
+	if !g.isCompatibleWithVerified(impact, issueVerified) {
+		// Verify or reopen the issue.
+		comment := g.prepareBugVerifiedUpdate(impact, issue, delta)
+		commentary = append(commentary, comment)
+		notify = true
+		// After the update, whether the issue was verified will have changed.
+		issueVerified = g.clusterResolved(impact)
+	}
+	if !hasLabel(issue, manualPriorityLabel) &&
+		!issueVerified &&
+		!g.isCompatibleWithPriority(impact, g.IssuePriority(issue)) {
+
+		if hasManuallySetPriority(comments) {
+			// We were not the last to update the priority of this issue.
+			// Set the 'manually controlled priority' label to reflect
+			// the state of this bug and avoid further attempts to update.
+			comment := prepareManualPriorityUpdate(issue, delta)
+			commentary = append(commentary, comment)
+		} else {
+			// We were the last to update the bug priority.
+			// Apply the priority update.
+			comment := g.preparePriorityUpdate(impact, issue, delta)
+			commentary = append(commentary, comment)
+			// Notify if new priority is higher than existing priority.
+			notify = notify || g.isHigherPriority(g.clusterPriority(impact), g.IssuePriority(issue))
+		}
+	}
+
+	bugName, err := fromMonorailIssueName(issue.Name)
+	if err != nil {
+		// This should never happen. It would mean monorail is feeding us
+		// invalid data.
+		panic("invalid monorail issue name: " + issue.Name)
+	}
+
+	commentary = append(commentary, g.linkToRuleComment(bugName))
+
+	update := &mpb.ModifyIssuesRequest{
+		Deltas: []*mpb.IssueDelta{
+			delta,
+		},
+		NotifyType:     mpb.NotifyType_NO_NOTIFICATION,
+		CommentContent: strings.Join(commentary, "\n\n"),
+	}
+	if notify {
+		update.NotifyType = mpb.NotifyType_EMAIL
+	}
+	return update
+}
+
+func (g *Generator) prepareBugVerifiedUpdate(impact *bugs.ClusterImpact, issue *mpb.Issue, update *mpb.IssueDelta) string {
+	resolved := g.clusterResolved(impact)
+	var status string
+	var message strings.Builder
+	if resolved {
+		status = VerifiedStatus
+
+		oldPriorityIndex := len(g.monorailCfg.Priorities) - 1
+		// A priority index of len(g.monorailCfg.Priorities) indicates
+		// a priority lower than the lowest defined priority (i.e. bug verified.)
+		newPriorityIndex := len(g.monorailCfg.Priorities)
+
+		message.WriteString("Because:\n")
+		message.WriteString(g.priorityDecreaseJustification(oldPriorityIndex, newPriorityIndex))
+		message.WriteString("Weetbix is marking the issue verified.")
+	} else {
+		if issue.GetOwner().GetUser() != "" {
+			status = AssignedStatus
+		} else {
+			status = UntriagedStatus
+		}
+
+		message.WriteString("Because:\n")
+		message.WriteString(g.explainThresholdsMet(impact, g.bugFilingThreshold))
+		message.WriteString("Weetbix has re-opened the bug.")
+	}
+	update.Issue.Status = &mpb.Issue_StatusValue{Status: status}
+	update.UpdateMask.Paths = append(update.UpdateMask.Paths, "status")
+	return message.String()
+}
+
+func prepareManualPriorityUpdate(issue *mpb.Issue, update *mpb.IssueDelta) string {
+	update.Issue.Labels = []*mpb.Issue_LabelValue{{
+		Label: manualPriorityLabel,
+	}}
+	update.UpdateMask.Paths = append(update.UpdateMask.Paths, "labels")
+	return fmt.Sprintf("The bug priority has been manually set. To re-enable automatic priority updates by Weetbix, remove the %s label.", manualPriorityLabel)
+}
+
+func (g *Generator) preparePriorityUpdate(impact *bugs.ClusterImpact, issue *mpb.Issue, update *mpb.IssueDelta) string {
+	newPriority := g.clusterPriority(impact)
+
+	update.Issue.FieldValues = []*mpb.FieldValue{
+		{
+			Field: g.priorityFieldName(),
+			Value: newPriority,
+		},
+	}
+	update.UpdateMask.Paths = append(update.UpdateMask.Paths, "field_values")
+
+	oldPriority := g.IssuePriority(issue)
+	oldPriorityIndex := g.indexOfPriority(oldPriority)
+	newPriorityIndex := g.indexOfPriority(newPriority)
+
+	if newPriorityIndex < oldPriorityIndex {
+		var message strings.Builder
+		message.WriteString("Because:\n")
+		message.WriteString(g.priorityIncreaseJustification(impact, oldPriorityIndex, newPriorityIndex))
+		message.WriteString(fmt.Sprintf("Weetbix has increased the bug priority from %v to %v.", oldPriority, newPriority))
+		return message.String()
+	} else {
+		var message strings.Builder
+		message.WriteString("Because:\n")
+		message.WriteString(g.priorityDecreaseJustification(oldPriorityIndex, newPriorityIndex))
+		message.WriteString(fmt.Sprintf("Weetbix has decreased the bug priority from %v to %v.", oldPriority, newPriority))
+		return message.String()
+	}
+}
+
+// hasManuallySetPriority returns whether the the given issue has a manually
+// controlled priority, based on its comments.
+func hasManuallySetPriority(comments []*mpb.Comment) bool {
+	// Example comment showing a user changing priority:
+	// {
+	// 	name: "projects/chromium/issues/915761/comments/1"
+	// 	state: ACTIVE
+	// 	type: COMMENT
+	// 	commenter: "users/2627516260"
+	// 	create_time: {
+	// 	  seconds: 1632111572
+	// 	}
+	// 	amendments: {
+	// 	  field_name: "Labels"
+	// 	  new_or_delta_value: "Pri-1"
+	// 	}
+	// }
+	for i := len(comments) - 1; i >= 0; i-- {
+		c := comments[i]
+
+		isManualPriorityUpdate := false
+		isRevertToAutomaticPriority := false
+		for _, a := range c.Amendments {
+			if a.FieldName == "Labels" {
+				deltaLabels := strings.Split(a.NewOrDeltaValue, " ")
+				for _, lbl := range deltaLabels {
+					if lbl == "-"+manualPriorityLabel {
+						isRevertToAutomaticPriority = true
+					}
+					if priorityRE.MatchString(lbl) {
+						if !isAutomationUser(c.Commenter) {
+							isManualPriorityUpdate = true
+						}
+					}
+				}
+			}
+		}
+		if isRevertToAutomaticPriority {
+			return false
+		}
+		if isManualPriorityUpdate {
+			return true
+		}
+	}
+	// No manual changes to priority indicates the bug is still under
+	// automatic control.
+	return false
+}
+
+func isAutomationUser(user string) bool {
+	for _, u := range AutomationUsers {
+		if u == user {
+			return true
+		}
+	}
+	return false
+}
+
+// hasLabel returns whether the bug the specified label.
+func hasLabel(issue *mpb.Issue, label string) bool {
+	for _, l := range issue.Labels {
+		if l.Label == label {
+			return true
+		}
+	}
+	return false
+}
+
+// IssuePriority returns the priority of the given issue.
+func (g *Generator) IssuePriority(issue *mpb.Issue) string {
+	priorityFieldName := g.priorityFieldName()
+	for _, fv := range issue.FieldValues {
+		if fv.Field == priorityFieldName {
+			return fv.Value
+		}
+	}
+	return ""
+}
+
+func issueVerified(issue *mpb.Issue) bool {
+	return issue.Status.Status == VerifiedStatus
+}
+
+// isHigherPriority returns whether priority p1 is higher than priority p2.
+// The passed strings are the priority field values as used in monorail. These
+// must be matched against monorail project configuration in order to
+// identify the ordering of the priorities.
+func (g *Generator) isHigherPriority(p1 string, p2 string) bool {
+	i1 := g.indexOfPriority(p1)
+	i2 := g.indexOfPriority(p2)
+	// Priorities are configured from highest to lowest, so higher priorities
+	// have lower indexes.
+	return i1 < i2
+}
+
+func (g *Generator) indexOfPriority(priority string) int {
+	for i, p := range g.monorailCfg.Priorities {
+		if p.Priority == priority {
+			return i
+		}
+	}
+	// If we can't find the priority, treat it as one lower than
+	// the lowest priority we know about.
+	return len(g.monorailCfg.Priorities)
+}
+
+// isCompatibleWithVerified returns whether the impact of the current cluster
+// is compatible with the issue having the given verified status, based on
+// configured thresholds and hysteresis.
+func (g *Generator) isCompatibleWithVerified(impact *bugs.ClusterImpact, verified bool) bool {
+	hysteresisPerc := g.monorailCfg.PriorityHysteresisPercent
+	lowestPriority := g.monorailCfg.Priorities[len(g.monorailCfg.Priorities)-1]
+	if verified {
+		// The issue is verified. Only reopen if we satisfied the bug-filing
+		// criteria. Bug-filing criteria is guaranteed to imply the criteria
+		// of the lowest priority level.
+		return !impact.MeetsThreshold(g.bugFilingThreshold)
+	} else {
+		// The issue is not verified. Only close if the impact falls
+		// below the threshold with hysteresis.
+		deflatedThreshold := bugs.InflateThreshold(lowestPriority.Threshold, -hysteresisPerc)
+		return impact.MeetsThreshold(deflatedThreshold)
+	}
+}
+
+// isCompatibleWithPriority returns whether the impact of the current cluster
+// is compatible with the issue having the given priority, based on
+// configured thresholds and hysteresis.
+func (g *Generator) isCompatibleWithPriority(impact *bugs.ClusterImpact, issuePriority string) bool {
+	index := g.indexOfPriority(issuePriority)
+	if index >= len(g.monorailCfg.Priorities) {
+		// Unknown priority in use. The priority should be updated to
+		// one of the configured priorities.
+		return false
+	}
+	hysteresisPerc := g.monorailCfg.PriorityHysteresisPercent
+	lowestAllowedPriority := g.clusterPriorityWithInflatedThresholds(impact, hysteresisPerc)
+	highestAllowedPriority := g.clusterPriorityWithInflatedThresholds(impact, -hysteresisPerc)
+
+	// Check the cluster has a priority no less than lowest priority
+	// and no greater than highest priority allowed by hysteresis.
+	// Note that a lower priority index corresponds to a higher
+	// priority (e.g. P0 <-> index 0, P1 <-> index 1, etc.)
+	return g.indexOfPriority(lowestAllowedPriority) >= index &&
+		index >= g.indexOfPriority(highestAllowedPriority)
+}
+
+// priorityDecreaseJustification outputs a human-readable justification
+// explaining why bug priority was decreased (including to the point where
+// a priority no longer applied, and the issue was marked as verified.)
+//
+// priorityIndex(s) are indices into the per-project priority list:
+//   g.monorailCfg.Priorities
+// If newPriorityIndex = len(g.monorailCfg.Priorities), it indicates
+// the decrease being justified is to a priority lower than the lowest
+// configured, i.e. a closed/verified issue.
+//
+// Example output:
+// "- Presubmit Runs Failed (1-day) < 15, and
+//  - Test Runs Failed (1-day) < 100"
+func (g *Generator) priorityDecreaseJustification(oldPriorityIndex, newPriorityIndex int) string {
+	if newPriorityIndex <= oldPriorityIndex {
+		// Priority did not change or increased.
+		return ""
+	}
+
+	// Priority decreased.
+	// To justify the decrease, it is sufficient to explain why we could no
+	// longer meet the criteria for the next-higher priority.
+	hysteresisPerc := g.monorailCfg.PriorityHysteresisPercent
+
+	// The next-higher priority level that we failed to meet.
+	failedToMeetThreshold := g.monorailCfg.Priorities[newPriorityIndex-1].Threshold
+	if newPriorityIndex == oldPriorityIndex+1 {
+		// We only dropped one priority level. That means we failed to meet the
+		// old threshold, even after applying hysteresis.
+		failedToMeetThreshold = bugs.InflateThreshold(failedToMeetThreshold, -hysteresisPerc)
+	}
+
+	return explainThresholdNotMet(failedToMeetThreshold)
+}
+
+func explainThresholdNotMet(thresoldNotMet *configpb.ImpactThreshold) string {
+	explanation := bugs.ExplainThresholdNotMet(thresoldNotMet)
+
+	var message strings.Builder
+	// As there may be multiple ways in which we could have met the
+	// threshold for the next-higher priority (due to the OR-
+	// disjunction of different metric thresholds), we must explain
+	// we did not meet any of them.
+	for i, exp := range explanation {
+		message.WriteString(fmt.Sprintf("- %s (%v-day) < %v", exp.Metric, exp.TimescaleDays, exp.Threshold))
+		if i < (len(explanation) - 1) {
+			message.WriteString(", and")
+		}
+		message.WriteString("\n")
+	}
+	return message.String()
+}
+
+// priorityIncreaseJustification outputs a human-readable justification
+// explaining why bug priority was increased (including for the case
+// where a bug was re-opened.)
+//
+// priorityIndex(s) are indices into the per-project priority list:
+//   g.monorailCfg.Priorities
+// The special index len(g.monorailCfg.Priorities) indicates an issue
+// with a priority lower than the lowest priority configured to be
+// assigned by Weetbix.
+//
+// Example output:
+// "- Presubmit Runs Failed (1-day) >= 15"
+func (g *Generator) priorityIncreaseJustification(impact *bugs.ClusterImpact, oldPriorityIndex, newPriorityIndex int) string {
+	if newPriorityIndex >= oldPriorityIndex {
+		// Priority did not change or decreased.
+		return ""
+	}
+
+	// Priority increased.
+	// To justify the increase, we must show that we met the criteria for
+	// each successively higher priority level.
+	hysteresisPerc := g.monorailCfg.PriorityHysteresisPercent
+
+	// Visit priorities in increasing priority order.
+	var thresholdsMet []*configpb.ImpactThreshold
+	for i := oldPriorityIndex - 1; i >= newPriorityIndex; i-- {
+		metThreshold := g.monorailCfg.Priorities[i].Threshold
+		if i == oldPriorityIndex-1 {
+			// For the first priority step up, we must have also exceeded
+			// hysteresis.
+			metThreshold = bugs.InflateThreshold(metThreshold, hysteresisPerc)
+		}
+		thresholdsMet = append(thresholdsMet, metThreshold)
+	}
+	return g.explainThresholdsMet(impact, thresholdsMet...)
+}
+
+func (g *Generator) explainThresholdsMet(impact *bugs.ClusterImpact, thresholds ...*configpb.ImpactThreshold) string {
+	var explanations []bugs.ThresholdExplanation
+	for _, t := range thresholds {
+		// There may be multiple ways in which we could have met the
+		// threshold for the next-higher priority (due to the OR-
+		// disjunction of different metric thresholds). This obtains
+		// just one of the ways in which we met it.
+		explanations = append(explanations, impact.ExplainThresholdMet(t))
+	}
+
+	// Remove redundant explanations.
+	// E.g. "Presubmit Runs Failed (1-day) >= 15"
+	// and "Presubmit Runs Failed (1-day) >= 30" can be merged to just
+	// "Presubmit Runs Failed (1-day) >= 30", because the latter
+	// trivially implies the former.
+	explanations = bugs.MergeThresholdMetExplanations(explanations)
+
+	var message strings.Builder
+	for i, exp := range explanations {
+		message.WriteString(fmt.Sprintf("- %s (%v-day) >= %v", exp.Metric, exp.TimescaleDays, exp.Threshold))
+		if i < (len(explanations) - 1) {
+			message.WriteString(", and")
+		}
+		message.WriteString("\n")
+	}
+	return message.String()
+}
+
+// clusterPriority returns the desired priority of the bug, if no hysteresis
+// is applied.
+func (g *Generator) clusterPriority(impact *bugs.ClusterImpact) string {
+	return g.clusterPriorityWithInflatedThresholds(impact, 0)
+}
+
+// clusterPriority returns the desired priority of the bug, if thresholds
+// are inflated or deflated with the given percentage.
+//
+// See bugs.InflateThreshold for the interpretation of inflationPercent.
+func (g *Generator) clusterPriorityWithInflatedThresholds(impact *bugs.ClusterImpact, inflationPercent int64) string {
+	// Default to using the lowest priority.
+	priority := g.monorailCfg.Priorities[len(g.monorailCfg.Priorities)-1]
+	for i := len(g.monorailCfg.Priorities) - 2; i >= 0; i-- {
+		p := g.monorailCfg.Priorities[i]
+		adjustedThreshold := bugs.InflateThreshold(p.Threshold, inflationPercent)
+		if !impact.MeetsThreshold(adjustedThreshold) {
+			// A cluster cannot reach a higher priority unless it has
+			// met the thresholds for all lower priorities.
+			break
+		}
+		priority = p
+	}
+	return priority.Priority
+}
+
+// clusterResolved returns the desired state of whether the cluster has been
+// verified, if no hysteresis has been applied.
+func (g *Generator) clusterResolved(impact *bugs.ClusterImpact) bool {
+	lowestPriority := g.monorailCfg.Priorities[len(g.monorailCfg.Priorities)-1]
+	return !impact.MeetsThreshold(lowestPriority.Threshold)
+}
+
+// sanitiseTitle removes tabs and line breaks from input, replacing them with
+// spaces, and truncates the output to the given number of runes.
+func sanitiseTitle(input string, maxLength int) string {
+	// Replace blocks of whitespace, including new lines and tabs, with just a
+	// single space.
+	strippedInput := whitespaceRE.ReplaceAllString(input, " ")
+
+	// Truncate to desired length.
+	runes := []rune(strippedInput)
+	if len(runes) > maxLength {
+		return string(runes[0:maxLength-3]) + "..."
+	}
+	return strippedInput
+}
diff --git a/analysis/internal/bugs/monorail/fakes.go b/analysis/internal/bugs/monorail/fakes.go
new file mode 100644
index 0000000..c8f2965
--- /dev/null
+++ b/analysis/internal/bugs/monorail/fakes.go
@@ -0,0 +1,419 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/proto/mask"
+	"go.chromium.org/luci/grpc/appstatus"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	emptypb "google.golang.org/protobuf/types/known/emptypb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+)
+
+// projectsRE matches valid monorail project references.
+var projectsRE = regexp.MustCompile(`projects/[a-z0-9\-_]+`)
+
+// fakeIssuesClient provides a fake implementation of a monorail client, for testing. See:
+// https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/api_proto/issues.proto
+type fakeIssuesClient struct {
+	store *FakeIssuesStore
+	// User is the identity of the user interacting with monorail.
+	user string
+}
+
+// UseFakeIssuesClient installs a given fake IssuesClient into the context so that
+// it is used instead of making RPCs to monorail. The client will behave as if
+// the given user is authenticated.
+func UseFakeIssuesClient(ctx context.Context, store *FakeIssuesStore, user string) context.Context {
+	issuesClient := &fakeIssuesClient{store: store, user: user}
+	projectsClient := &fakeProjectsClient{store: store}
+	return context.WithValue(ctx, &testMonorailClientKey, &Client{
+		issuesClient:   mpb.IssuesClient(issuesClient),
+		projectsClient: mpb.ProjectsClient(projectsClient),
+	})
+}
+
+func (f *fakeIssuesClient) GetIssue(ctx context.Context, in *mpb.GetIssueRequest, opts ...grpc.CallOption) (*mpb.Issue, error) {
+	issue := f.issueByName(in.Name)
+	if issue == nil {
+		return nil, errors.New("issue not found")
+	}
+	// Copy proto so that if the consumer modifies the proto,
+	// the stored proto does not change.
+	return CopyIssue(issue.Issue), nil
+}
+
+func (f *fakeIssuesClient) issueByName(name string) *IssueData {
+	for _, issue := range f.store.Issues {
+		if issue.Issue.Name == name {
+			return issue
+		}
+	}
+	return nil
+}
+
+func (f *fakeIssuesClient) BatchGetIssues(ctx context.Context, in *mpb.BatchGetIssuesRequest, opts ...grpc.CallOption) (*mpb.BatchGetIssuesResponse, error) {
+	result := &mpb.BatchGetIssuesResponse{}
+	for _, name := range in.Names {
+		issue := f.issueByName(name)
+		if issue == nil {
+			return nil, fmt.Errorf("issue %q not found", name)
+		}
+		// Copy proto so that if the consumer modifies the proto,
+		// the stored proto does not change.
+		result.Issues = append(result.Issues, CopyIssue(issue.Issue))
+	}
+	return result, nil
+}
+
+func (f *fakeIssuesClient) SearchIssues(ctx context.Context, in *mpb.SearchIssuesRequest, opts ...grpc.CallOption) (*mpb.SearchIssuesResponse, error) {
+	return nil, errors.New("not implemented")
+}
+
+func (f *fakeIssuesClient) ListComments(ctx context.Context, in *mpb.ListCommentsRequest, opts ...grpc.CallOption) (*mpb.ListCommentsResponse, error) {
+	issue := f.issueByName(in.Parent)
+	if issue == nil {
+		return nil, fmt.Errorf("issue %q not found", in.Parent)
+	}
+	startIndex := 0
+	if in.PageToken != "" {
+		start, err := strconv.Atoi(in.PageToken)
+		if err != nil {
+			return nil, fmt.Errorf("invalid page token %q", in.PageToken)
+		}
+		startIndex = start
+	}
+	// The specification for ListComments says that 100 is both the default
+	// and the maximum value.
+	pageSize := int(in.PageSize)
+	if pageSize > 100 || pageSize <= 0 {
+		pageSize = 100
+	}
+	endIndex := startIndex + pageSize
+	finished := false
+	if endIndex > len(issue.Comments) {
+		endIndex = len(issue.Comments)
+		finished = true
+	}
+	comments := issue.Comments[startIndex:endIndex]
+
+	result := &mpb.ListCommentsResponse{
+		Comments: CopyComments(comments),
+	}
+	if !finished {
+		result.NextPageToken = strconv.Itoa(endIndex)
+	}
+	return result, nil
+}
+
+// Implements a version of ModifyIssues that operates on local data instead of using monorail service.
+// Reference implementation:
+// https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/issues_servicer.py?q=%22def%20ModifyIssues%22
+// https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/converters.py?q=IngestIssueDeltas&type=cs
+func (f *fakeIssuesClient) ModifyIssues(ctx context.Context, in *mpb.ModifyIssuesRequest, opts ...grpc.CallOption) (*mpb.ModifyIssuesResponse, error) {
+	// Current implementation would erroneously update the first issue
+	// if the delta for the second issue failed validation. Currently our
+	// fakes don't need this fidelity so it has not been implemented.
+	if len(in.Deltas) > 1 {
+		return nil, errors.New("not implemented for more than one delta")
+	}
+	var updatedIssues []*mpb.Issue
+	for _, delta := range in.Deltas {
+		name := delta.Issue.Name
+		issue := f.issueByName(name)
+		if issue == nil {
+			return nil, fmt.Errorf("issue %q not found", name)
+		}
+		if !delta.UpdateMask.IsValid(issue.Issue) {
+			return nil, fmt.Errorf("update mask for issue %q not valid", name)
+		}
+		const isFieldNameJSON = false
+		const isUpdateMask = true
+		m, err := mask.FromFieldMask(delta.UpdateMask, issue.Issue, isFieldNameJSON, isUpdateMask)
+		if err != nil {
+			return nil, errors.Annotate(err, "update mask for issue %q not valid", name).Err()
+		}
+
+		// Effect deletions.
+		if len(delta.BlockedOnIssuesRemove) > 0 || len(delta.BlockingIssuesRemove) > 0 ||
+			len(delta.CcsRemove) > 0 || len(delta.ComponentsRemove) > 0 || len(delta.FieldValsRemove) > 0 {
+			return nil, errors.New("some removals are not supported by the current fake")
+		}
+		issue.Issue.Labels = mergeLabelDeletions(issue.Issue.Labels, delta.LabelsRemove)
+
+		// Keep only the bits of the delta that are also in the field mask.
+		filteredDelta := &mpb.Issue{}
+		if err := m.Merge(delta.Issue, filteredDelta); err != nil {
+			return nil, errors.Annotate(err, "failed to merge for issue %q", name).Err()
+		}
+
+		// Items in the delta's lists (like field values and labels) are treated as item-wise
+		// additions or updates, not as a list-wise replacement.
+		mergedDelta := CopyIssue(filteredDelta)
+		mergedDelta.FieldValues = mergeFieldValues(issue.Issue.FieldValues, filteredDelta.FieldValues)
+		mergedDelta.Labels = mergeLabels(issue.Issue.Labels, filteredDelta.Labels)
+		if len(mergedDelta.BlockedOnIssueRefs) > 0 || len(mergedDelta.BlockingIssueRefs) > 0 ||
+			len(mergedDelta.CcUsers) > 0 || len(mergedDelta.Components) > 0 {
+			return nil, errors.New("some additions are not supported by the current fake")
+		}
+
+		// Apply the delta to the saved issue.
+		if err := m.Merge(mergedDelta, issue.Issue); err != nil {
+			return nil, errors.Annotate(err, "failed to merge for issue %q", name).Err()
+		}
+
+		// If the status was modified.
+		if mergedDelta.Status != nil {
+			now := clock.Now(ctx)
+			issue.Issue.StatusModifyTime = timestamppb.New(now)
+		}
+
+		// Currently only some amendments are created. Support for other
+		// amendments can be added if needed.
+		amendments := f.createAmendments(filteredDelta.Labels, delta.LabelsRemove, filteredDelta.FieldValues)
+		issue.Comments = append(issue.Comments, &mpb.Comment{
+			Name:       fmt.Sprintf("%s/comment/%v", name, len(issue.Comments)),
+			State:      mpb.IssueContentState_ACTIVE,
+			Type:       mpb.Comment_DESCRIPTION,
+			Content:    in.CommentContent,
+			Commenter:  f.user,
+			Amendments: amendments,
+		})
+		if in.NotifyType == mpb.NotifyType_EMAIL {
+			issue.NotifyCount++
+		}
+		// Copy the proto so that if the consumer modifies it, the saved proto
+		// is not changed.
+		updatedIssues = append(updatedIssues, CopyIssue(issue.Issue))
+	}
+	result := &mpb.ModifyIssuesResponse{
+		Issues: updatedIssues,
+	}
+	return result, nil
+}
+
+func (f *fakeIssuesClient) createAmendments(labelUpdate []*mpb.Issue_LabelValue, labelDeletions []string, fieldUpdates []*mpb.FieldValue) []*mpb.Comment_Amendment {
+	var amendments []string
+	for _, l := range labelUpdate {
+		amendments = append(amendments, l.Label)
+	}
+	for _, l := range labelDeletions {
+		amendments = append(amendments, "-"+l)
+	}
+	for _, fv := range fieldUpdates {
+		if fv.Field == f.store.PriorityFieldName {
+			amendments = append(amendments, "Pri-"+fv.Value)
+		}
+		// Other field updates are currently not fully supported by the fake.
+	}
+
+	if len(amendments) > 0 {
+		return []*mpb.Comment_Amendment{
+			{
+				FieldName:       "Labels",
+				NewOrDeltaValue: strings.Join(amendments, " "),
+			},
+		}
+	}
+	return nil
+}
+
+// mergeFieldValues applies the updates in update to the existing field values
+// and return the result.
+func mergeFieldValues(existing []*mpb.FieldValue, update []*mpb.FieldValue) []*mpb.FieldValue {
+	merge := make(map[string]*mpb.FieldValue)
+	for _, fv := range existing {
+		merge[fv.Field] = fv
+	}
+	for _, fv := range update {
+		merge[fv.Field] = fv
+	}
+	var result []*mpb.FieldValue
+	for _, v := range merge {
+		result = append(result, v)
+	}
+	// Ensure the result of merging is predictable, as the order we iterate
+	// over maps is not guaranteed.
+	SortFieldValues(result)
+	return result
+}
+
+// SortFieldValues sorts the given labels in alphabetical order.
+func SortFieldValues(input []*mpb.FieldValue) {
+	sort.Slice(input, func(i, j int) bool {
+		return input[i].Field < input[j].Field
+	})
+}
+
+// mergeLabels applies the updates in update to the existing labels
+// and return the result.
+func mergeLabels(existing []*mpb.Issue_LabelValue, update []*mpb.Issue_LabelValue) []*mpb.Issue_LabelValue {
+	merge := make(map[string]*mpb.Issue_LabelValue)
+	for _, l := range existing {
+		merge[l.Label] = l
+	}
+	for _, l := range update {
+		merge[l.Label] = l
+	}
+	var result []*mpb.Issue_LabelValue
+	for _, v := range merge {
+		result = append(result, v)
+	}
+	// Ensure the result of merging is predictable, as the order we iterate
+	// over maps is not guaranteed.
+	SortLabels(result)
+	return result
+}
+
+func mergeLabelDeletions(existing []*mpb.Issue_LabelValue, deletes []string) []*mpb.Issue_LabelValue {
+	merge := make(map[string]*mpb.Issue_LabelValue)
+	for _, l := range existing {
+		merge[l.Label] = l
+	}
+	for _, l := range deletes {
+		delete(merge, l)
+	}
+	var result []*mpb.Issue_LabelValue
+	for _, v := range merge {
+		result = append(result, v)
+	}
+	// Ensure the result of merging is predictable, as the order we iterate
+	// over maps is not guaranteed.
+	SortLabels(result)
+	return result
+}
+
+// SortLabels sorts the given labels in alphabetical order.
+func SortLabels(input []*mpb.Issue_LabelValue) {
+	sort.Slice(input, func(i, j int) bool {
+		return input[i].Label < input[j].Label
+	})
+}
+
+func (f *fakeIssuesClient) ModifyIssueApprovalValues(ctx context.Context, in *mpb.ModifyIssueApprovalValuesRequest, opts ...grpc.CallOption) (*mpb.ModifyIssueApprovalValuesResponse, error) {
+	return nil, errors.New("not implemented")
+}
+
+func (f *fakeIssuesClient) ListApprovalValues(ctx context.Context, in *mpb.ListApprovalValuesRequest, opts ...grpc.CallOption) (*mpb.ListApprovalValuesResponse, error) {
+	return nil, errors.New("not implemented")
+}
+
+func (f *fakeIssuesClient) ModifyCommentState(ctx context.Context, in *mpb.ModifyCommentStateRequest, opts ...grpc.CallOption) (*mpb.ModifyCommentStateResponse, error) {
+	return nil, errors.New("not implemented")
+}
+
+func (f *fakeIssuesClient) MakeIssueFromTemplate(ctx context.Context, in *mpb.MakeIssueFromTemplateRequest, opts ...grpc.CallOption) (*mpb.Issue, error) {
+	return nil, errors.New("not implemented")
+}
+
+func (f *fakeIssuesClient) MakeIssue(ctx context.Context, in *mpb.MakeIssueRequest, opts ...grpc.CallOption) (*mpb.Issue, error) {
+	if !projectsRE.MatchString(in.Parent) {
+		return nil, errors.New("parent project must be specified and match the form 'projects/{project_id}'")
+	}
+
+	now := clock.Now(ctx)
+
+	// Copy the proto so that if the request proto is later modified, the save proto is not changed.
+	saved := CopyIssue(in.Issue)
+	saved.Name = fmt.Sprintf("%s/issues/%v", in.Parent, f.store.NextID)
+	saved.Reporter = f.user
+	saved.StatusModifyTime = timestamppb.New(now)
+
+	// Ensure data is stored in sorted order, to ensure comparisons in test code are stable.
+	SortFieldValues(saved.FieldValues)
+	SortLabels(saved.Labels)
+
+	f.store.NextID++
+	issue := &IssueData{
+		Issue: saved,
+		Comments: []*mpb.Comment{
+			{
+				Name:      fmt.Sprintf("%s/comment/1", saved.Name),
+				State:     mpb.IssueContentState_ACTIVE,
+				Type:      mpb.Comment_DESCRIPTION,
+				Content:   in.Description,
+				Commenter: in.Issue.Reporter,
+			},
+		},
+		NotifyCount: 0,
+	}
+	if in.NotifyType == mpb.NotifyType_EMAIL {
+		issue.NotifyCount = 1
+	}
+
+	f.store.Issues = append(f.store.Issues, issue)
+
+	// Copy the proto so that if the consumer modifies it, the saved proto is not changed.
+	return CopyIssue(saved), nil
+}
+
+type fakeProjectsClient struct {
+	store *FakeIssuesStore
+}
+
+// Creates a new FieldDef (custom field).
+func (f *fakeProjectsClient) CreateFieldDef(ctx context.Context, in *mpb.CreateFieldDefRequest, opts ...grpc.CallOption) (*mpb.FieldDef, error) {
+	return nil, errors.New("not implemented")
+}
+
+// Gets a ComponentDef given the reference.
+func (f *fakeProjectsClient) GetComponentDef(ctx context.Context, in *mpb.GetComponentDefRequest, opts ...grpc.CallOption) (*mpb.ComponentDef, error) {
+	for _, c := range f.store.ComponentNames {
+		if c == in.Name {
+			return &mpb.ComponentDef{
+				Name:  c,
+				State: mpb.ComponentDef_ACTIVE,
+			}, nil
+		}
+	}
+	return nil, appstatus.GRPCifyAndLog(ctx, appstatus.Error(codes.NotFound, "not found"))
+}
+
+// Creates a new ComponentDef.
+func (f *fakeProjectsClient) CreateComponentDef(ctx context.Context, in *mpb.CreateComponentDefRequest, opts ...grpc.CallOption) (*mpb.ComponentDef, error) {
+	return nil, errors.New("not implemented")
+}
+
+// Deletes a ComponentDef.
+func (f *fakeProjectsClient) DeleteComponentDef(ctx context.Context, in *mpb.DeleteComponentDefRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) {
+	return nil, errors.New("not implemented")
+}
+
+// Returns all templates for specified project.
+func (f *fakeProjectsClient) ListIssueTemplates(ctx context.Context, in *mpb.ListIssueTemplatesRequest, opts ...grpc.CallOption) (*mpb.ListIssueTemplatesResponse, error) {
+	return nil, errors.New("not implemented")
+}
+
+// Returns all field defs for specified project.
+func (f *fakeProjectsClient) ListComponentDefs(ctx context.Context, in *mpb.ListComponentDefsRequest, opts ...grpc.CallOption) (*mpb.ListComponentDefsResponse, error) {
+	return nil, errors.New("not implemented")
+}
+
+// Returns all projects hosted on Monorail.
+func (f *fakeProjectsClient) ListProjects(ctx context.Context, in *mpb.ListProjectsRequest, opts ...grpc.CallOption) (*mpb.ListProjectsResponse, error) {
+	return nil, errors.New("not implemented")
+}
diff --git a/analysis/internal/bugs/monorail/manager.go b/analysis/internal/bugs/monorail/manager.go
new file mode 100644
index 0000000..dea2127
--- /dev/null
+++ b/analysis/internal/bugs/monorail/manager.go
@@ -0,0 +1,354 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// monorailRe matches monorail issue names, like
+// "monorail/{monorail_project}/{numeric_id}".
+var monorailRe = regexp.MustCompile(`^projects/([a-z0-9\-_]+)/issues/([0-9]+)$`)
+
+// componentRE matches valid full monorail component names.
+var componentRE = regexp.MustCompile(`^[a-zA-Z]([-_]?[a-zA-Z0-9])+(\>[a-zA-Z]([-_]?[a-zA-Z0-9])+)*$`)
+
+var textPBMultiline = prototext.MarshalOptions{
+	Multiline: true,
+}
+
+// monorailPageSize is the maximum number of issues that can be requested
+// through GetIssues at a time. This limit is set by monorail.
+const monorailPageSize = 100
+
+// BugManager controls the creation of, and updates to, monorail bugs
+// for clusters.
+type BugManager struct {
+	client *Client
+	// The GAE APP ID, e.g. "chops-weetbix".
+	appID string
+	// The LUCI Project.
+	project string
+	// The snapshot of configuration to use for the project.
+	projectCfg *configpb.ProjectConfig
+	// The generator used to generate updates to monorail bugs.
+	generator *Generator
+	// Simulate, if set, tells BugManager not to make mutating changes
+	// to monorail but only log the changes it would make. Must be set
+	// when running locally as RPCs made from developer systems will
+	// appear as that user, which breaks the detection of user-made
+	// priority changes vs system-made priority changes.
+	Simulate bool
+}
+
+// NewBugManager initialises a new bug manager, using the specified
+// monorail client.
+func NewBugManager(client *Client, appID, project string, projectCfg *configpb.ProjectConfig) (*BugManager, error) {
+	g, err := NewGenerator(appID, projectCfg)
+	if err != nil {
+		return nil, errors.Annotate(err, "create issue generator").Err()
+	}
+	return &BugManager{
+		client:     client,
+		appID:      appID,
+		project:    project,
+		projectCfg: projectCfg,
+		generator:  g,
+		Simulate:   false,
+	}, nil
+}
+
+// Create creates a new bug for the given request, returning its name, or
+// any encountered error.
+func (m *BugManager) Create(ctx context.Context, request *bugs.CreateRequest) (string, error) {
+	components := request.MonorailComponents
+	if m.appID == "chops-weetbix" {
+		// In production, do not apply components to bugs as they are not yet
+		// ready to be surfaced widely.
+		components = nil
+	}
+	components, err := m.filterToValidComponents(ctx, components)
+	if err != nil {
+		return "", errors.Annotate(err, "validate components").Err()
+	}
+
+	makeReq := m.generator.PrepareNew(request.Impact, request.Description, components)
+	var bugName string
+	if m.Simulate {
+		logging.Debugf(ctx, "Would create Monorail issue: %s", textPBMultiline.Format(makeReq))
+		bugName = fmt.Sprintf("%s/12345678", m.projectCfg.Monorail.Project)
+	} else {
+		// Save the issue in Monorail.
+		issue, err := m.client.MakeIssue(ctx, makeReq)
+		if err != nil {
+			return "", errors.Annotate(err, "create issue in monorail").Err()
+		}
+		bugName, err = fromMonorailIssueName(issue.Name)
+		if err != nil {
+			return "", errors.Annotate(err, "parsing monorail issue name").Err()
+		}
+	}
+
+	modifyReq, err := m.generator.PrepareLinkComment(bugName)
+	if err != nil {
+		return "", errors.Annotate(err, "prepare link comment").Err()
+	}
+	if m.Simulate {
+		logging.Debugf(ctx, "Would update Monorail issue: %s", textPBMultiline.Format(modifyReq))
+		return "", bugs.ErrCreateSimulated
+	}
+	if err := m.client.ModifyIssues(ctx, modifyReq); err != nil {
+		return "", errors.Annotate(err, "update issue").Err()
+	}
+	bugs.BugsCreatedCounter.Add(ctx, 1, m.project, "monorail")
+	return bugName, nil
+}
+
+// filterToValidComponents limits the given list of components to only those
+// components which exist in monorail, and are active.
+func (m *BugManager) filterToValidComponents(ctx context.Context, components []string) ([]string, error) {
+	var result []string
+	for _, c := range components {
+		if !componentRE.MatchString(c) {
+			continue
+		}
+		existsAndActive, err := m.client.GetComponentExistsAndActive(ctx, m.projectCfg.Monorail.Project, c)
+		if err != nil {
+			return nil, err
+		}
+		if !existsAndActive {
+			continue
+		}
+		result = append(result, c)
+	}
+	return result, nil
+}
+
+type clusterIssue struct {
+	impact *bugs.ClusterImpact
+	issue  *mpb.Issue
+}
+
+// Update updates the specified list of bugs.
+func (m *BugManager) Update(ctx context.Context, request []bugs.BugUpdateRequest) ([]bugs.BugUpdateResponse, error) {
+	// Fetch issues for bugs to update.
+	issues, err := m.fetchIssues(ctx, request)
+	if err != nil {
+		return nil, err
+	}
+
+	var responses []bugs.BugUpdateResponse
+	for i, req := range request {
+		issue := issues[i]
+		isDuplicate := issue.Status.Status == DuplicateStatus
+		shouldArchive := shouldArchiveRule(ctx, issue, req.IsManagingBug)
+		if !isDuplicate && !shouldArchive && req.IsManagingBug && req.Impact != nil {
+			if m.generator.NeedsUpdate(req.Impact, issue) {
+				comments, err := m.client.ListComments(ctx, issue.Name)
+				if err != nil {
+					return nil, err
+				}
+				updateReq := m.generator.MakeUpdate(req.Impact, issue, comments)
+				if m.Simulate {
+					logging.Debugf(ctx, "Would update Monorail issue: %s", textPBMultiline.Format(updateReq))
+				} else {
+					if err := m.client.ModifyIssues(ctx, updateReq); err != nil {
+						return nil, errors.Annotate(err, "failed to update monorail issue %s", req.Bug.ID).Err()
+					}
+					bugs.BugsUpdatedCounter.Add(ctx, 1, m.project, "monorail")
+				}
+			}
+		}
+		responses = append(responses, bugs.BugUpdateResponse{
+			IsDuplicate:   isDuplicate,
+			ShouldArchive: shouldArchive && !isDuplicate,
+		})
+	}
+	return responses, nil
+}
+
+// shouldArchiveRule determines if the rule managing the given issue should
+// be archived.
+func shouldArchiveRule(ctx context.Context, issue *mpb.Issue, isManaging bool) bool {
+	// If the bug is set to a status like "Archived", immediately archive
+	// the rule as well. We should not re-open such a bug.
+	if _, ok := ArchivedStatuses[issue.Status.Status]; ok {
+		return true
+	}
+	now := clock.Now(ctx)
+	if isManaging {
+		// If Weetbix is managing the bug,
+		// more than 30 days since the issue was verified.
+		return issue.Status.Status == VerifiedStatus &&
+			now.Sub(issue.StatusModifyTime.AsTime()).Hours() >= 30*24
+	} else {
+		// If the user is managing the bug,
+		// more than 30 days since the issue was closed.
+		_, ok := ClosedStatuses[issue.Status.Status]
+		return ok &&
+			now.Sub(issue.StatusModifyTime.AsTime()).Hours() >= 30*24
+	}
+}
+
+// GetMergedInto reads the bug (if any) the given bug was merged into.
+// If the given bug is not merged into another bug, this returns nil.
+func (m *BugManager) GetMergedInto(ctx context.Context, bug bugs.BugID) (*bugs.BugID, error) {
+	if bug.System != bugs.MonorailSystem {
+		// Indicates an implementation error with the caller.
+		panic("monorail bug manager can only deal with monorail bugs")
+	}
+	name, err := toMonorailIssueName(bug.ID)
+	if err != nil {
+		return nil, err
+	}
+	issue, err := m.client.GetIssue(ctx, name)
+	if err != nil {
+		return nil, err
+	}
+	result, err := mergedIntoBug(issue)
+	if err != nil {
+		return nil, errors.Annotate(err, "resolving canoncial merged into bug").Err()
+	}
+	return result, nil
+}
+
+// Unduplicate updates the given bug to no longer be marked as duplicating
+// another bug, posting the given message on the bug.
+func (m *BugManager) Unduplicate(ctx context.Context, bug bugs.BugID, message string) error {
+	if bug.System != bugs.MonorailSystem {
+		// Indicates an implementation error with the caller.
+		panic("monorail bug manager can only deal with monorail bugs")
+	}
+	req, err := m.generator.MarkAvailable(bug.ID, message)
+	if err != nil {
+		return errors.Annotate(err, "mark issue as available").Err()
+	}
+	if m.Simulate {
+		logging.Debugf(ctx, "Would update Monorail issue: %s", textPBMultiline.Format(req))
+	} else {
+		if err := m.client.ModifyIssues(ctx, req); err != nil {
+			return errors.Annotate(err, "failed to unduplicate monorail issue %s", bug.ID).Err()
+		}
+	}
+	return nil
+}
+
+var buganizerExtRefRe = regexp.MustCompile(`^b/([1-9][0-9]{0,16})$`)
+
+// mergedIntoBug determines if the given bug is a duplicate of another
+// bug, and if so, what the identity of that bug is.
+func mergedIntoBug(issue *mpb.Issue) (*bugs.BugID, error) {
+	if issue.Status.Status == DuplicateStatus &&
+		issue.MergedIntoIssueRef != nil {
+		if issue.MergedIntoIssueRef.Issue != "" {
+			name, err := fromMonorailIssueName(issue.MergedIntoIssueRef.Issue)
+			if err != nil {
+				// This should not happen unless monorail or the
+				// implementation here is broken.
+				return nil, err
+			}
+			return &bugs.BugID{
+				System: bugs.MonorailSystem,
+				ID:     name,
+			}, nil
+		}
+		matches := buganizerExtRefRe.FindStringSubmatch(issue.MergedIntoIssueRef.ExtIdentifier)
+		if matches == nil {
+			// A non-buganizer external issue tracker was used. This is not
+			// supported by us, treat the issue as not duplicate of something
+			// else and let auto-updating kick the bug out of duplicate state
+			// if there is still impact. The user should manually resolve the
+			// situation.
+			return nil, fmt.Errorf("unsupported non-monorail non-buganizer bug reference: %s", issue.MergedIntoIssueRef.ExtIdentifier)
+		}
+		return &bugs.BugID{
+			System: bugs.BuganizerSystem,
+			ID:     matches[1],
+		}, nil
+	}
+	return nil, nil
+}
+
+// fetchIssues fetches monorail issues using the internal bug names like
+// {monorail_project}/{issue_id}.
+func (m *BugManager) fetchIssues(ctx context.Context, request []bugs.BugUpdateRequest) ([]*mpb.Issue, error) {
+	// Calculate the number of requests required, rounding up
+	// to the nearest page.
+	pages := (len(request) + (monorailPageSize - 1)) / monorailPageSize
+
+	response := make([]*mpb.Issue, 0, len(request))
+	for i := 0; i < pages; i++ {
+		// Divide names into pages of monorailPageSize.
+		pageEnd := (i + 1) * monorailPageSize
+		if pageEnd > len(request) {
+			pageEnd = len(request)
+		}
+		requestPage := request[i*monorailPageSize : pageEnd]
+
+		var names []string
+		for _, requestItem := range requestPage {
+			if requestItem.Bug.System != bugs.MonorailSystem {
+				// Indicates an implementation error with the caller.
+				panic("monorail bug manager can only deal with monorail bugs")
+			}
+			name, err := toMonorailIssueName(requestItem.Bug.ID)
+			if err != nil {
+				return nil, err
+			}
+			names = append(names, name)
+		}
+		// Guarantees result array in 1:1 correspondence to requested names.
+		issues, err := m.client.BatchGetIssues(ctx, names)
+		if err != nil {
+			return nil, err
+		}
+		response = append(response, issues...)
+	}
+	return response, nil
+}
+
+// toMonorailIssueName converts an internal bug name like
+// "{monorail_project}/{numeric_id}" to a monorail issue name like
+// "projects/{project}/issues/{numeric_id}".
+func toMonorailIssueName(bug string) (string, error) {
+	parts := bugs.MonorailBugIDRe.FindStringSubmatch(bug)
+	if parts == nil {
+		return "", fmt.Errorf("invalid bug %q", bug)
+	}
+	return fmt.Sprintf("projects/%s/issues/%s", parts[1], parts[2]), nil
+}
+
+// fromMonorailIssueName converts a monorail issue name like
+// "projects/{project}/issues/{numeric_id}" to an internal bug name like
+// "{monorail_project}/{numeric_id}".
+func fromMonorailIssueName(name string) (string, error) {
+	parts := monorailRe.FindStringSubmatch(name)
+	if parts == nil {
+		return "", fmt.Errorf("invalid monorail issue name %q", name)
+	}
+	return fmt.Sprintf("%s/%s", parts[1], parts[2]), nil
+}
diff --git a/analysis/internal/bugs/monorail/manager_test.go b/analysis/internal/bugs/monorail/manager_test.go
new file mode 100644
index 0000000..f045533
--- /dev/null
+++ b/analysis/internal/bugs/monorail/manager_test.go
@@ -0,0 +1,673 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"google.golang.org/genproto/protobuf/field_mask"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+func NewCreateRequest() *bugs.CreateRequest {
+	cluster := &bugs.CreateRequest{
+		Description: &clustering.ClusterDescription{
+			Title:       "ClusterID",
+			Description: "Tests are failing with reason: Some failure reason.",
+		},
+		MonorailComponents: []string{
+			"Blink>Layout",
+			"Blink>Network",
+			"Blink>Invalid",
+		},
+	}
+	return cluster
+}
+
+func TestManager(t *testing.T) {
+	t.Parallel()
+
+	Convey("With Bug Manager", t, func() {
+		ctx := context.Background()
+		f := &FakeIssuesStore{
+			NextID:            100,
+			PriorityFieldName: "projects/chromium/fieldDefs/11",
+			ComponentNames: []string{
+				"projects/chromium/componentDefs/Blink",
+				"projects/chromium/componentDefs/Blink>Layout",
+				"projects/chromium/componentDefs/Blink>Network",
+			},
+		}
+		user := AutomationUsers[0]
+		cl, err := NewClient(UseFakeIssuesClient(ctx, f, user), "myhost")
+		So(err, ShouldBeNil)
+		monorailCfgs := ChromiumTestConfig()
+		bugFilingThreshold := ChromiumTestBugFilingThreshold()
+		projectCfg := &configpb.ProjectConfig{
+			Monorail:           monorailCfgs,
+			BugFilingThreshold: bugFilingThreshold,
+		}
+		bm, err := NewBugManager(cl, "chops-weetbix-test", "luciproject", projectCfg)
+		So(err, ShouldBeNil)
+
+		now := time.Date(2040, time.January, 1, 2, 3, 4, 5, time.UTC)
+		ctx, tc := testclock.UseTime(ctx, now)
+
+		Convey("Create", func() {
+			c := NewCreateRequest()
+			c.Impact = ChromiumLowP1Impact()
+
+			Convey("With reason-based failure cluster", func() {
+				reason := `Expected equality of these values:
+					"Expected_Value"
+					my_expr.evaluate(123)
+						Which is: "Unexpected_Value"`
+				c.Description.Title = reason
+				c.Description.Description = "A cluster of failures has been found with reason: " + reason
+
+				bug, err := bm.Create(ctx, c)
+				So(err, ShouldBeNil)
+				So(bug, ShouldEqual, "chromium/100")
+				So(len(f.Issues), ShouldEqual, 1)
+				issue := f.Issues[0]
+
+				So(issue.Issue, ShouldResembleProto, &mpb.Issue{
+					Name:             "projects/chromium/issues/100",
+					Summary:          "Tests are failing: Expected equality of these values: \"Expected_Value\" my_expr.evaluate(123) Which is: \"Unexpected_Value\"",
+					Reporter:         AutomationUsers[0],
+					Owner:            &mpb.Issue_UserValue{User: ChromiumDefaultAssignee},
+					State:            mpb.IssueContentState_ACTIVE,
+					Status:           &mpb.Issue_StatusValue{Status: "Untriaged"},
+					StatusModifyTime: timestamppb.New(now),
+					FieldValues: []*mpb.FieldValue{
+						{
+							// Type field.
+							Field: "projects/chromium/fieldDefs/10",
+							Value: "Bug",
+						},
+						{
+							// Priority field.
+							Field: "projects/chromium/fieldDefs/11",
+							Value: "1",
+						},
+					},
+					Components: []*mpb.Issue_ComponentValue{
+						{Component: "projects/chromium/componentDefs/Blink>Layout"},
+						{Component: "projects/chromium/componentDefs/Blink>Network"},
+					},
+					Labels: []*mpb.Issue_LabelValue{{
+						Label: "Restrict-View-Google",
+					}, {
+						Label: "Weetbix-Auto-Filed",
+					}},
+				})
+				So(len(issue.Comments), ShouldEqual, 2)
+				So(issue.Comments[0].Content, ShouldContainSubstring, reason)
+				So(issue.Comments[0].Content, ShouldNotContainSubstring, "ClusterIDShouldNotAppearInOutput")
+				// Link to cluster page should appear in output.
+				So(issue.Comments[1].Content, ShouldContainSubstring, "https://chops-weetbix-test.appspot.com/b/chromium/100")
+				So(issue.NotifyCount, ShouldEqual, 1)
+			})
+			Convey("With test name failure cluster", func() {
+				c.Description.Title = "ninja://:blink_web_tests/media/my-suite/my-test.html"
+				c.Description.Description = "A test is failing " + c.Description.Title
+
+				bug, err := bm.Create(ctx, c)
+				So(err, ShouldBeNil)
+				So(bug, ShouldEqual, "chromium/100")
+				So(len(f.Issues), ShouldEqual, 1)
+				issue := f.Issues[0]
+
+				So(issue.Issue, ShouldResembleProto, &mpb.Issue{
+					Name:             "projects/chromium/issues/100",
+					Summary:          "Tests are failing: ninja://:blink_web_tests/media/my-suite/my-test.html",
+					Reporter:         AutomationUsers[0],
+					Owner:            &mpb.Issue_UserValue{User: ChromiumDefaultAssignee},
+					State:            mpb.IssueContentState_ACTIVE,
+					Status:           &mpb.Issue_StatusValue{Status: "Untriaged"},
+					StatusModifyTime: timestamppb.New(now),
+					FieldValues: []*mpb.FieldValue{
+						{
+							// Type field.
+							Field: "projects/chromium/fieldDefs/10",
+							Value: "Bug",
+						},
+						{
+							// Priority field.
+							Field: "projects/chromium/fieldDefs/11",
+							Value: "1",
+						},
+					},
+					Components: []*mpb.Issue_ComponentValue{
+						{Component: "projects/chromium/componentDefs/Blink>Layout"},
+						{Component: "projects/chromium/componentDefs/Blink>Network"},
+					},
+					Labels: []*mpb.Issue_LabelValue{{
+						Label: "Restrict-View-Google",
+					}, {
+						Label: "Weetbix-Auto-Filed",
+					}},
+				})
+				So(len(issue.Comments), ShouldEqual, 2)
+				So(issue.Comments[0].Content, ShouldContainSubstring, "ninja://:blink_web_tests/media/my-suite/my-test.html")
+				// Link to cluster page should appear in output.
+				So(issue.Comments[1].Content, ShouldContainSubstring, "https://chops-weetbix-test.appspot.com/b/chromium/100")
+				So(issue.NotifyCount, ShouldEqual, 1)
+			})
+			Convey("Does nothing if in simulation mode", func() {
+				bm.Simulate = true
+				_, err := bm.Create(ctx, c)
+				So(err, ShouldEqual, bugs.ErrCreateSimulated)
+				So(len(f.Issues), ShouldEqual, 0)
+			})
+		})
+		Convey("Update", func() {
+			c := NewCreateRequest()
+			c.Impact = ChromiumP2Impact()
+			bug, err := bm.Create(ctx, c)
+			So(err, ShouldBeNil)
+			So(bug, ShouldEqual, "chromium/100")
+			So(len(f.Issues), ShouldEqual, 1)
+			So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "2")
+
+			bugsToUpdate := []bugs.BugUpdateRequest{
+				{
+					Bug:           bugs.BugID{System: bugs.MonorailSystem, ID: bug},
+					Impact:        c.Impact,
+					IsManagingBug: true,
+				},
+			}
+			expectedResponse := []bugs.BugUpdateResponse{
+				{IsDuplicate: false},
+			}
+			updateDoesNothing := func() {
+				originalIssues := CopyIssuesStore(f)
+				response, err := bm.Update(ctx, bugsToUpdate)
+				So(err, ShouldBeNil)
+				So(response, ShouldResemble, expectedResponse)
+				So(f, ShouldResembleIssuesStore, originalIssues)
+			}
+			// Create a monorail client that interacts with monorail
+			// as an end-user. This is needed as we distinguish user
+			// updates to the bug from system updates.
+			user := "users/100"
+			usercl, err := NewClient(UseFakeIssuesClient(ctx, f, user), "myhost")
+			So(err, ShouldBeNil)
+
+			Convey("If impact unchanged, does nothing", func() {
+				updateDoesNothing()
+			})
+			Convey("If impact changed", func() {
+				bugsToUpdate[0].Impact = ChromiumP3Impact()
+				Convey("Does not reduce priority if impact within hysteresis range", func() {
+					bugsToUpdate[0].Impact = ChromiumHighP3Impact()
+
+					updateDoesNothing()
+				})
+				Convey("Does not update bug if IsManagingBug false", func() {
+					bugsToUpdate[0].Impact = ChromiumClosureImpact()
+					bugsToUpdate[0].IsManagingBug = false
+
+					updateDoesNothing()
+				})
+				Convey("Does not update bug if Impact unset", func() {
+					// Simulate valid impact not being available, e.g. due
+					// to ongoing reclustering.
+					bugsToUpdate[0].Impact = nil
+
+					updateDoesNothing()
+				})
+				Convey("Reduces priority in response to reduced impact", func() {
+					bugsToUpdate[0].Impact = ChromiumP3Impact()
+					originalNotifyCount := f.Issues[0].NotifyCount
+					response, err := bm.Update(ctx, bugsToUpdate)
+					So(err, ShouldBeNil)
+					So(response, ShouldResemble, expectedResponse)
+					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "3")
+
+					So(f.Issues[0].Comments, ShouldHaveLength, 3)
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+						"Because:\n"+
+							"- Test Runs Failed (1-day) < 9, and\n"+
+							"- Test Results Failed (1-day) < 90\n"+
+							"Weetbix has decreased the bug priority from 2 to 3.")
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+						"https://chops-weetbix-test.appspot.com/b/chromium/100")
+
+					// Does not notify.
+					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount)
+
+					// Verify repeated update has no effect.
+					updateDoesNothing()
+				})
+				Convey("Does not increase priority if impact within hysteresis range", func() {
+					bugsToUpdate[0].Impact = ChromiumLowP1Impact()
+
+					updateDoesNothing()
+				})
+				Convey("Increases priority in response to increased impact (single-step)", func() {
+					bugsToUpdate[0].Impact = ChromiumP1Impact()
+
+					originalNotifyCount := f.Issues[0].NotifyCount
+					response, err := bm.Update(ctx, bugsToUpdate)
+					So(err, ShouldBeNil)
+					So(response, ShouldResemble, expectedResponse)
+					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "1")
+
+					So(f.Issues[0].Comments, ShouldHaveLength, 3)
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+						"Because:\n"+
+							"- Test Results Failed (1-day) >= 550\n"+
+							"Weetbix has increased the bug priority from 2 to 1.")
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+						"https://chops-weetbix-test.appspot.com/b/chromium/100")
+
+					// Notified the increase.
+					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount+1)
+
+					// Verify repeated update has no effect.
+					updateDoesNothing()
+				})
+				Convey("Increases priority in response to increased impact (multi-step)", func() {
+					bugsToUpdate[0].Impact = ChromiumP0Impact()
+
+					originalNotifyCount := f.Issues[0].NotifyCount
+					response, err := bm.Update(ctx, bugsToUpdate)
+					So(err, ShouldBeNil)
+					So(response, ShouldResemble, expectedResponse)
+					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "0")
+
+					expectedComment := "Because:\n" +
+						"- Test Results Failed (1-day) >= 1000\n" +
+						"Weetbix has increased the bug priority from 2 to 0."
+					So(f.Issues[0].Comments, ShouldHaveLength, 3)
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring, expectedComment)
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+						"https://chops-weetbix-test.appspot.com/b/chromium/100")
+
+					// Notified the increase.
+					So(f.Issues[0].NotifyCount, ShouldEqual, originalNotifyCount+1)
+
+					// Verify repeated update has no effect.
+					updateDoesNothing()
+				})
+				Convey("Does not adjust priority if priority manually set", func() {
+					updateReq := updateBugPriorityRequest(f.Issues[0].Issue.Name, "0")
+					err = usercl.ModifyIssues(ctx, updateReq)
+					So(err, ShouldBeNil)
+					So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "0")
+
+					// Check the update sets the label.
+					expectedIssue := CopyIssue(f.Issues[0].Issue)
+					expectedIssue.Labels = append(expectedIssue.Labels, &mpb.Issue_LabelValue{
+						Label: manualPriorityLabel,
+					})
+					SortLabels(expectedIssue.Labels)
+
+					So(f.Issues[0].NotifyCount, ShouldEqual, 1)
+					response, err := bm.Update(ctx, bugsToUpdate)
+					So(err, ShouldBeNil)
+					So(response, ShouldResemble, expectedResponse)
+					So(f.Issues[0].Issue, ShouldResembleProto, expectedIssue)
+
+					// Does not notify.
+					So(f.Issues[0].NotifyCount, ShouldEqual, 1)
+
+					// Check repeated update does nothing more.
+					updateDoesNothing()
+
+					Convey("Unless manual priority cleared", func() {
+						updateReq := removeLabelRequest(f.Issues[0].Issue.Name, manualPriorityLabel)
+						err = usercl.ModifyIssues(ctx, updateReq)
+						So(err, ShouldBeNil)
+						So(hasLabel(f.Issues[0].Issue, manualPriorityLabel), ShouldBeFalse)
+
+						response, err := bm.Update(ctx, bugsToUpdate)
+						So(response, ShouldResemble, expectedResponse)
+						So(err, ShouldBeNil)
+						So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "3")
+
+						// Verify repeated update has no effect.
+						updateDoesNothing()
+					})
+				})
+				Convey("Does nothing if in simulation mode", func() {
+					bm.Simulate = true
+					updateDoesNothing()
+				})
+				Convey("Does nothing if Restrict-View-Google is unset", func() {
+					// This requirement comes from security review, see crbug.com/1245877.
+					updateReq := removeLabelRequest(f.Issues[0].Issue.Name, restrictViewLabel)
+					err = usercl.ModifyIssues(ctx, updateReq)
+					So(err, ShouldBeNil)
+					So(hasLabel(f.Issues[0].Issue, restrictViewLabel), ShouldBeFalse)
+
+					updateDoesNothing()
+				})
+			})
+			Convey("If impact falls below lowest priority threshold", func() {
+				bugsToUpdate[0].Impact = ChromiumClosureImpact()
+				Convey("Update leaves bug open if impact within hysteresis range", func() {
+					bugsToUpdate[0].Impact = ChromiumP3LowestBeforeClosureImpact()
+
+					// Update may reduce the priority from P2 to P3, but the
+					// issue should be left open. This is because hysteresis on
+					// priority and issue verified state is applied separately.
+					response, err := bm.Update(ctx, bugsToUpdate)
+					So(err, ShouldBeNil)
+					So(response, ShouldResemble, expectedResponse)
+					So(f.Issues[0].Issue.Status.Status, ShouldEqual, UntriagedStatus)
+				})
+				Convey("Update closes bug", func() {
+					response, err := bm.Update(ctx, bugsToUpdate)
+					So(err, ShouldBeNil)
+					So(response, ShouldResemble, expectedResponse)
+					So(f.Issues[0].Issue.Status.Status, ShouldEqual, VerifiedStatus)
+
+					expectedComment := "Because:\n" +
+						"- Test Results Failed (1-day) < 45, and\n" +
+						"- Test Results Failed (3-day) < 272, and\n" +
+						"- Test Results Failed (7-day) < 1\n" +
+						"Weetbix is marking the issue verified."
+					So(f.Issues[0].Comments, ShouldHaveLength, 3)
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring, expectedComment)
+					So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+						"https://chops-weetbix-test.appspot.com/b/chromium/100")
+
+					// Verify repeated update has no effect.
+					updateDoesNothing()
+
+					Convey("Does not reopen bug if impact within hysteresis range", func() {
+						bugsToUpdate[0].Impact = ChromiumHighestNotFiledImpact()
+
+						updateDoesNothing()
+					})
+
+					Convey("Rules for verified bugs archived after 30 days", func() {
+						tc.Add(time.Hour * 24 * 30)
+
+						expectedResponse := []bugs.BugUpdateResponse{
+							{
+								ShouldArchive: true,
+							},
+						}
+						originalIssues := CopyIssuesStore(f)
+						response, err := bm.Update(ctx, bugsToUpdate)
+						So(err, ShouldBeNil)
+						So(response, ShouldResemble, expectedResponse)
+						So(f, ShouldResembleIssuesStore, originalIssues)
+					})
+
+					Convey("If impact increases, bug is re-opened with correct priority", func() {
+						bugsToUpdate[0].Impact = ChromiumP3Impact()
+						Convey("Issue has owner", func() {
+							// Update issue owner.
+							updateReq := updateOwnerRequest(f.Issues[0].Issue.Name, "users/100")
+							err = usercl.ModifyIssues(ctx, updateReq)
+							So(err, ShouldBeNil)
+							So(f.Issues[0].Issue.Owner.GetUser(), ShouldEqual, "users/100")
+
+							// Issue should return to "Assigned" status.
+							response, err := bm.Update(ctx, bugsToUpdate)
+							So(err, ShouldBeNil)
+							So(response, ShouldResemble, expectedResponse)
+							So(f.Issues[0].Issue.Status.Status, ShouldEqual, AssignedStatus)
+							So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "3")
+
+							expectedComment := "Because:\n" +
+								"- Test Results Failed (1-day) >= 75\n" +
+								"Weetbix has re-opened the bug.\n\n" +
+								"Because:\n" +
+								"- Test Runs Failed (1-day) < 9, and\n" +
+								"- Test Results Failed (1-day) < 90\n" +
+								"Weetbix has decreased the bug priority from 2 to 3."
+							So(f.Issues[0].Comments, ShouldHaveLength, 5)
+							So(f.Issues[0].Comments[4].Content, ShouldContainSubstring, expectedComment)
+							So(f.Issues[0].Comments[4].Content, ShouldContainSubstring,
+								"https://chops-weetbix-test.appspot.com/b/chromium/100")
+
+							// Verify repeated update has no effect.
+							updateDoesNothing()
+						})
+						Convey("Issue has no owner", func() {
+							// Remove owner.
+							updateReq := updateOwnerRequest(f.Issues[0].Issue.Name, "")
+							err = usercl.ModifyIssues(ctx, updateReq)
+							So(err, ShouldBeNil)
+							So(f.Issues[0].Issue.Owner.GetUser(), ShouldEqual, "")
+
+							// Issue should return to "Untriaged" status.
+							response, err := bm.Update(ctx, bugsToUpdate)
+							So(err, ShouldBeNil)
+							So(response, ShouldResemble, expectedResponse)
+							So(f.Issues[0].Issue.Status.Status, ShouldEqual, UntriagedStatus)
+							So(ChromiumTestIssuePriority(f.Issues[0].Issue), ShouldEqual, "3")
+
+							expectedComment := "Because:\n" +
+								"- Test Results Failed (1-day) >= 75\n" +
+								"Weetbix has re-opened the bug.\n\n" +
+								"Because:\n" +
+								"- Test Runs Failed (1-day) < 9, and\n" +
+								"- Test Results Failed (1-day) < 90\n" +
+								"Weetbix has decreased the bug priority from 2 to 3."
+							So(f.Issues[0].Comments, ShouldHaveLength, 5)
+							So(f.Issues[0].Comments[4].Content, ShouldContainSubstring, expectedComment)
+							So(f.Issues[0].Comments[4].Content, ShouldContainSubstring,
+								"https://chops-weetbix-test.appspot.com/b/chromium/100")
+
+							// Verify repeated update has no effect.
+							updateDoesNothing()
+						})
+					})
+				})
+			})
+			Convey("If bug duplicate", func() {
+				f.Issues[0].Issue.Status.Status = DuplicateStatus
+
+				expectedResponse := []bugs.BugUpdateResponse{
+					{
+						IsDuplicate: true,
+					},
+				}
+				originalIssues := CopyIssuesStore(f)
+				response, err := bm.Update(ctx, bugsToUpdate)
+				So(err, ShouldBeNil)
+				So(response, ShouldResemble, expectedResponse)
+				So(f, ShouldResembleIssuesStore, originalIssues)
+			})
+			Convey("Rule not managing a bug archived after 30 days of the bug being in any closed state", func() {
+				tc.Add(time.Hour * 24 * 30)
+
+				bugsToUpdate[0].IsManagingBug = false
+				f.Issues[0].Issue.Status.Status = FixedStatus
+
+				expectedResponse := []bugs.BugUpdateResponse{
+					{
+						ShouldArchive: true,
+					},
+				}
+				originalIssues := CopyIssuesStore(f)
+				response, err := bm.Update(ctx, bugsToUpdate)
+				So(err, ShouldBeNil)
+				So(response, ShouldResemble, expectedResponse)
+				So(f, ShouldResembleIssuesStore, originalIssues)
+			})
+			Convey("Rule managing a bug not archived after 30 days of the bug being in fixed state", func() {
+				tc.Add(time.Hour * 24 * 30)
+
+				// If Weetbix is mangaging the bug state, the fixed state
+				// means the bug is still not verified. Do not archive the
+				// rule.
+				bugsToUpdate[0].IsManagingBug = true
+				f.Issues[0].Issue.Status.Status = FixedStatus
+
+				updateDoesNothing()
+			})
+			Convey("Rules archived immediately if bug archived", func() {
+				f.Issues[0].Issue.Status.Status = "Archived"
+
+				expectedResponse := []bugs.BugUpdateResponse{
+					{
+						ShouldArchive: true,
+					},
+				}
+				originalIssues := CopyIssuesStore(f)
+				response, err := bm.Update(ctx, bugsToUpdate)
+				So(err, ShouldBeNil)
+				So(response, ShouldResemble, expectedResponse)
+				So(f, ShouldResembleIssuesStore, originalIssues)
+			})
+		})
+		Convey("GetMergedInto", func() {
+			c := NewCreateRequest()
+			c.Impact = ChromiumP2Impact()
+			bug, err := bm.Create(ctx, c)
+			So(err, ShouldBeNil)
+			So(bug, ShouldEqual, "chromium/100")
+			So(len(f.Issues), ShouldEqual, 1)
+
+			bugID := bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/100"}
+			Convey("Merged into monorail bug", func() {
+				f.Issues[0].Issue.Status.Status = DuplicateStatus
+				f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
+					Issue: "projects/testproject/issues/99",
+				}
+
+				result, err := bm.GetMergedInto(ctx, bugID)
+				So(err, ShouldEqual, nil)
+				So(result, ShouldResemble, &bugs.BugID{
+					System: bugs.MonorailSystem,
+					ID:     "testproject/99",
+				})
+			})
+			Convey("Merged into buganizer bug", func() {
+				f.Issues[0].Issue.Status.Status = DuplicateStatus
+				f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
+					ExtIdentifier: "b/1234",
+				}
+
+				result, err := bm.GetMergedInto(ctx, bugID)
+				So(err, ShouldEqual, nil)
+				So(result, ShouldResemble, &bugs.BugID{
+					System: bugs.BuganizerSystem,
+					ID:     "1234",
+				})
+			})
+			Convey("Not merged into any bug", func() {
+				// While MergedIntoIssueRef is set, the bug status is not
+				// set to "Duplicate", so this value should be ignored.
+				f.Issues[0].Issue.Status.Status = UntriagedStatus
+				f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
+					ExtIdentifier: "b/1234",
+				}
+
+				result, err := bm.GetMergedInto(ctx, bugID)
+				So(err, ShouldEqual, nil)
+				So(result, ShouldBeNil)
+			})
+		})
+		Convey("Unduplicate", func() {
+			c := NewCreateRequest()
+			c.Impact = ChromiumP2Impact()
+			bug, err := bm.Create(ctx, c)
+			So(err, ShouldBeNil)
+			So(bug, ShouldEqual, "chromium/100")
+			So(f.Issues, ShouldHaveLength, 1)
+			So(f.Issues[0].Comments, ShouldHaveLength, 2)
+
+			f.Issues[0].Issue.Status.Status = DuplicateStatus
+			f.Issues[0].Issue.MergedIntoIssueRef = &mpb.IssueRef{
+				Issue: "projects/testproject/issues/99",
+			}
+
+			bugID := bugs.BugID{System: bugs.MonorailSystem, ID: "chromium/100"}
+			err = bm.Unduplicate(ctx, bugID, "Some comment.")
+			So(err, ShouldBeNil)
+
+			So(f.Issues[0].Issue.Status, ShouldNotEqual, DuplicateStatus)
+			So(f.Issues[0].Comments, ShouldHaveLength, 3)
+			So(f.Issues[0].Comments[2].Content, ShouldContainSubstring, "Some comment.")
+			So(f.Issues[0].Comments[2].Content, ShouldContainSubstring,
+				"https://chops-weetbix-test.appspot.com/b/chromium/100")
+		})
+	})
+}
+
+func updateOwnerRequest(name string, owner string) *mpb.ModifyIssuesRequest {
+	return &mpb.ModifyIssuesRequest{
+		Deltas: []*mpb.IssueDelta{
+			{
+				Issue: &mpb.Issue{
+					Name: name,
+					Owner: &mpb.Issue_UserValue{
+						User: owner,
+					},
+				},
+				UpdateMask: &field_mask.FieldMask{
+					Paths: []string{"owner"},
+				},
+			},
+		},
+		CommentContent: "User comment.",
+	}
+}
+
+func updateBugPriorityRequest(name string, priority string) *mpb.ModifyIssuesRequest {
+	return &mpb.ModifyIssuesRequest{
+		Deltas: []*mpb.IssueDelta{
+			{
+				Issue: &mpb.Issue{
+					Name: name,
+					FieldValues: []*mpb.FieldValue{
+						{
+							Field: "projects/chromium/fieldDefs/11",
+							Value: priority,
+						},
+					},
+				},
+				UpdateMask: &field_mask.FieldMask{
+					Paths: []string{"field_values"},
+				},
+			},
+		},
+		CommentContent: "User comment.",
+	}
+}
+
+func removeLabelRequest(name string, label string) *mpb.ModifyIssuesRequest {
+	return &mpb.ModifyIssuesRequest{
+		Deltas: []*mpb.IssueDelta{
+			{
+				Issue: &mpb.Issue{
+					Name: name,
+				},
+				UpdateMask:   &field_mask.FieldMask{},
+				LabelsRemove: []string{label},
+			},
+		},
+		CommentContent: "User comment.",
+	}
+}
diff --git a/analysis/internal/bugs/monorail/monorail.go b/analysis/internal/bugs/monorail/monorail.go
new file mode 100644
index 0000000..9bfc1b3
--- /dev/null
+++ b/analysis/internal/bugs/monorail/monorail.go
@@ -0,0 +1,189 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/grpc/prpc"
+	"go.chromium.org/luci/server/auth"
+	"google.golang.org/grpc"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/protobuf/proto"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+)
+
+var testMonorailClientKey = "used in tests only for setting the monorail client test double"
+
+// maxCommentPageSize is the maximum number of comments that can be returned
+// by Monorail in one go.
+const maxCommentPageSize = 100
+
+func newClient(ctx context.Context, host string) (*prpc.Client, error) {
+	// Reference: go/dogfood-monorail-v3-api
+	apiHost := fmt.Sprintf("api-dot-%v", host)
+	audience := fmt.Sprintf("https://%v", host)
+
+	t, err := auth.GetRPCTransport(ctx, auth.AsSelf, auth.WithIDTokenAudience(audience))
+	if err != nil {
+		return nil, err
+	}
+	// httpClient is able to make HTTP requests authenticated with
+	// ID tokens.
+	httpClient := &http.Client{Transport: t}
+	monorailPRPCClient := &prpc.Client{
+		C:    httpClient,
+		Host: apiHost,
+	}
+	return monorailPRPCClient, nil
+}
+
+// Creates a new Monorail client. Host is the monorail host to use,
+// e.g. monorail-prod.appspot.com.
+func NewClient(ctx context.Context, host string) (*Client, error) {
+	if testClient, ok := ctx.Value(&testMonorailClientKey).(*Client); ok {
+		return testClient, nil
+	}
+
+	client, err := newClient(ctx, host)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Client{
+		issuesClient:   mpb.NewIssuesPRPCClient(client),
+		projectsClient: mpb.NewProjectsPRPCClient(client),
+	}, nil
+}
+
+// Client is a client to communicate with the Monorail issue tracker.
+type Client struct {
+	issuesClient   mpb.IssuesClient
+	projectsClient mpb.ProjectsClient
+}
+
+// GetIssue retrieves the details of a monorail issue. Name should
+// follow the format "projects/<projectid>/issues/<issueid>".
+func (c *Client) GetIssue(ctx context.Context, name string) (*mpb.Issue, error) {
+	req := mpb.GetIssueRequest{Name: name}
+	resp, err := c.issuesClient.GetIssue(ctx, &req)
+	if err != nil {
+		return nil, errors.Annotate(err, "GetIssue %q", name).Err()
+	}
+	return resp, nil
+}
+
+// BatchGetIssues gets the details of the specified monorail issues.
+// At most 100 issues can be queried at once. It is guaranteed
+// that the i_th issue in the result will match the i_th issue
+// requested. It is valid to request the same issue multiple
+// times in the same request.
+func (c *Client) BatchGetIssues(ctx context.Context, names []string) ([]*mpb.Issue, error) {
+	var deduplicatedNames []string
+	requestedNames := make(map[string]bool)
+	for _, name := range names {
+		if !requestedNames[name] {
+			deduplicatedNames = append(deduplicatedNames, name)
+			requestedNames[name] = true
+		}
+	}
+	req := mpb.BatchGetIssuesRequest{Names: deduplicatedNames}
+	resp, err := c.issuesClient.BatchGetIssues(ctx, &req)
+	if err != nil {
+		return nil, errors.Annotate(err, "BatchGetIssues %v", deduplicatedNames).Err()
+	}
+	issuesByName := make(map[string]*mpb.Issue)
+	for _, issue := range resp.Issues {
+		issuesByName[issue.Name] = issue
+	}
+	var result []*mpb.Issue
+	for _, name := range names {
+		// Copy the proto to avoid an issue being aliased in
+		// the result if the same issue is requested multiple times.
+		// The caller should be able to assume each issue returned
+		// is a distinct object.
+		issue := &mpb.Issue{}
+		proto.Merge(issue, issuesByName[name])
+		result = append(result, issue)
+	}
+	return result, nil
+}
+
+// MakeIssue creates the given issue in monorail, adding the specified
+// description.
+func (c *Client) MakeIssue(ctx context.Context, req *mpb.MakeIssueRequest) (*mpb.Issue, error) {
+	issue, err := c.issuesClient.MakeIssue(ctx, req)
+	if err != nil {
+		return nil, errors.Annotate(err, "MakeIssue").Err()
+	}
+	return issue, err
+}
+
+// ListComments lists comments present on the given issue. At most
+// 1000 comments are returned.
+func (c *Client) ListComments(ctx context.Context, name string) ([]*mpb.Comment, error) {
+	var result []*mpb.Comment
+
+	pageToken := ""
+
+	// Scan at most 10 pages.
+	for p := 0; p < 10; p++ {
+		req := mpb.ListCommentsRequest{
+			Parent:    name,
+			PageSize:  maxCommentPageSize,
+			PageToken: pageToken,
+		}
+		resp, err := c.issuesClient.ListComments(ctx, &req)
+		if err != nil {
+			return nil, errors.Annotate(err, "ListComments %q", name).Err()
+		}
+		result = append(result, resp.Comments...)
+		pageToken = resp.NextPageToken
+		if pageToken == "" {
+			break
+		}
+	}
+
+	return result, nil
+}
+
+// ModifyIssues modifies the given issue.
+func (c *Client) ModifyIssues(ctx context.Context, req *mpb.ModifyIssuesRequest) error {
+	_, err := c.issuesClient.ModifyIssues(ctx, req)
+	if err != nil {
+		return errors.Annotate(err, "ModifyIssues").Err()
+	}
+	return nil
+}
+
+// GetComponentExistsAndActive returns true if the given component exists
+// and is active in monorail.
+func (c *Client) GetComponentExistsAndActive(ctx context.Context, project string, component string) (bool, error) {
+	request := &mpb.GetComponentDefRequest{
+		Name: fmt.Sprintf("projects/%s/componentDefs/%s", project, component),
+	}
+	response, err := c.projectsClient.GetComponentDef(ctx, request)
+	if err != nil {
+		if grpc.Code(err) == codes.NotFound {
+			return false, nil
+		}
+		return false, errors.Annotate(err, "fetching components").Err()
+	}
+	return response.State == mpb.ComponentDef_ACTIVE, nil
+}
diff --git a/analysis/internal/bugs/monorail/monorail_test.go b/analysis/internal/bugs/monorail/monorail_test.go
new file mode 100644
index 0000000..663504a
--- /dev/null
+++ b/analysis/internal/bugs/monorail/monorail_test.go
@@ -0,0 +1,163 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"google.golang.org/genproto/protobuf/field_mask"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+)
+
+func TestClient(t *testing.T) {
+	t.Parallel()
+
+	Convey("With Existing Issue Data", t, func() {
+		issue1 := NewIssueData(1)
+		issue2 := NewIssueData(2)
+		issue3 := NewIssueData(3)
+		f := &FakeIssuesStore{
+			Issues: []*IssueData{issue1, issue2, issue3},
+			NextID: 4,
+		}
+		ctx := UseFakeIssuesClient(context.Background(), f, "user@chromium.org")
+
+		now := time.Date(2044, time.April, 4, 4, 4, 4, 4, time.UTC)
+		ctx, _ = testclock.UseTime(ctx, now)
+
+		Convey("Get issue", func() {
+			c, err := NewClient(ctx, "monorailhost")
+			So(err, ShouldBeNil)
+			result, err := c.GetIssue(ctx, "projects/monorailproject/issues/1")
+			So(err, ShouldBeNil)
+			So(result, ShouldResembleProto, issue1.Issue)
+		})
+		Convey("Batch get issues", func() {
+			c, err := NewClient(ctx, "monorailhost")
+			So(err, ShouldBeNil)
+			names := []string{
+				"projects/monorailproject/issues/1",
+				"projects/monorailproject/issues/2",
+				"projects/monorailproject/issues/1",
+				"projects/monorailproject/issues/2",
+				"projects/monorailproject/issues/3",
+			}
+			result, err := c.BatchGetIssues(ctx, names)
+			So(err, ShouldBeNil)
+			So(result, ShouldResembleProto, []*mpb.Issue{issue1.Issue, issue2.Issue, issue1.Issue, issue2.Issue, issue3.Issue})
+		})
+		Convey("Make issue", func() {
+			issue := NewIssue(4)
+			issue.Name = ""
+			req := &mpb.MakeIssueRequest{
+				Parent:      "projects/monorailproject",
+				Issue:       issue,
+				Description: "Description",
+				NotifyType:  mpb.NotifyType_NO_NOTIFICATION,
+			}
+
+			c, err := NewClient(ctx, "monorailhost")
+			So(err, ShouldBeNil)
+			result, err := c.MakeIssue(ctx, req)
+			So(err, ShouldBeNil)
+			expectedResult := NewIssue(4)
+			expectedResult.StatusModifyTime = timestamppb.New(now)
+			So(result, ShouldResembleProto, expectedResult)
+
+			comments, err := c.ListComments(ctx, result.Name)
+			So(err, ShouldBeNil)
+			So(len(comments), ShouldEqual, 1)
+			So(comments[0].Content, ShouldEqual, "Description")
+		})
+		Convey("List comments", func() {
+			Convey("Single comment", func() {
+				c, err := NewClient(ctx, "monorailhost")
+				So(err, ShouldBeNil)
+				comments, err := c.ListComments(ctx, "projects/monorailproject/issues/1")
+				So(err, ShouldBeNil)
+				So(len(comments), ShouldEqual, 1)
+				So(comments, ShouldResembleProto, issue1.Comments)
+			})
+			Convey("Many comments", func() {
+				issue := NewIssueData(4)
+				for i := 2; i <= 3*maxCommentPageSize; i++ {
+					issue.Comments = append(issue.Comments, NewComment(issue.Issue.Name, i))
+				}
+				f.Issues = append(f.Issues, issue)
+
+				c, err := NewClient(ctx, "monorailhost")
+				So(err, ShouldBeNil)
+				comments, err := c.ListComments(ctx, issue.Issue.Name)
+				So(err, ShouldBeNil)
+				So(comments, ShouldResembleProto, issue.Comments)
+			})
+		})
+		Convey("Modify issue", func() {
+			issue1.Issue.Labels = []*mpb.Issue_LabelValue{
+				{Label: "Test-Label1"},
+			}
+
+			c, err := NewClient(ctx, "monorailhost")
+			So(err, ShouldBeNil)
+
+			req := &mpb.ModifyIssuesRequest{
+				Deltas: []*mpb.IssueDelta{
+					{
+						Issue: &mpb.Issue{
+							Name:   issue1.Issue.Name,
+							Status: &mpb.Issue_StatusValue{Status: VerifiedStatus},
+							Labels: []*mpb.Issue_LabelValue{
+								{
+									Label: "Test-Label2",
+								},
+							},
+						},
+						UpdateMask: &field_mask.FieldMask{
+							Paths: []string{"labels", "status"},
+						},
+					},
+				},
+				CommentContent: "Changing status and labels.",
+			}
+			err = c.ModifyIssues(ctx, req)
+			So(err, ShouldBeNil)
+
+			expectedData := NewIssueData(1)
+			expectedData.Issue.Labels = []*mpb.Issue_LabelValue{
+				{Label: "Test-Label1"},
+				{Label: "Test-Label2"},
+			}
+			expectedData.Issue.Status = &mpb.Issue_StatusValue{Status: VerifiedStatus}
+			expectedData.Issue.StatusModifyTime = timestamppb.New(now)
+
+			read, err := c.GetIssue(ctx, issue1.Issue.Name)
+			So(err, ShouldBeNil)
+			So(read, ShouldResembleProto, expectedData.Issue)
+
+			comments, err := c.ListComments(ctx, issue1.Issue.Name)
+			So(err, ShouldBeNil)
+			So(len(comments), ShouldEqual, 2)
+			So(comments[0], ShouldResembleProto, expectedData.Comments[0])
+			So(comments[1].Content, ShouldEqual, "Changing status and labels.")
+		})
+	})
+}
diff --git a/analysis/internal/bugs/monorail/testconfig.go b/analysis/internal/bugs/monorail/testconfig.go
new file mode 100644
index 0000000..de08d21
--- /dev/null
+++ b/analysis/internal/bugs/monorail/testconfig.go
@@ -0,0 +1,205 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"github.com/golang/protobuf/proto"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// ChromiumTestPriorityField is the resource name of the priority field
+// that is consistent with ChromiumTestConfig.
+const ChromiumTestPriorityField = "projects/chromium/fieldDefs/11"
+
+// ChromiumTestTypeField is the resource name of the type field
+// that is consistent with ChromiumTestConfig.
+const ChromiumTestTypeField = "projects/chromium/fieldDefs/10"
+
+// ChromiumTestConfig provides chromium-like configuration for tests
+// to use.
+func ChromiumTestConfig() *configpb.MonorailProject {
+	projectCfg := &configpb.MonorailProject{
+		Project: "chromium",
+		DefaultFieldValues: []*configpb.MonorailFieldValue{
+			{
+				FieldId: 10,
+				Value:   "Bug",
+			},
+		},
+		PriorityFieldId: 11,
+		Priorities: []*configpb.MonorailPriority{
+			{
+				Priority: "0",
+				Threshold: &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(1000),
+					},
+					TestRunsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(100),
+					},
+				},
+			},
+			{
+				Priority: "1",
+				Threshold: &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(500),
+					},
+					TestRunsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(50),
+					},
+				},
+			},
+			{
+				Priority: "2",
+				Threshold: &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(100),
+					},
+					TestRunsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(10),
+					},
+				},
+			},
+			{
+				Priority: "3",
+				// Should be less onerous than the bug-filing thresholds
+				// used in BugUpdater tests, to avoid bugs that were filed
+				// from being immediately closed.
+				Threshold: &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{
+						OneDay:   proto.Int64(50),
+						ThreeDay: proto.Int64(300),
+						SevenDay: proto.Int64(1), // Set to 1 so that we check hysteresis never rounds down to 0 and prevents bugs from closing.
+					},
+				},
+			},
+		},
+		PriorityHysteresisPercent: 10,
+	}
+	return projectCfg
+}
+
+func ChromiumTestBugFilingThreshold() *configpb.ImpactThreshold {
+	return &configpb.ImpactThreshold{
+		// Should be equally or more onerous than the lowest
+		// priority threshold.
+		TestResultsFailed: &configpb.MetricThreshold{
+			OneDay: proto.Int64(75),
+		},
+	}
+}
+
+// ChromiumP0Impact returns cluster impact that is consistent with a P0 bug.
+func ChromiumP0Impact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 1500,
+		},
+	}
+}
+
+// ChromiumP1Impact returns cluster impact that is consistent with a P1 bug.
+func ChromiumP1Impact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 750,
+		},
+	}
+}
+
+// ChromiumLowP1Impact returns cluster impact that is consistent with a P1
+// bug, but if hysteresis is applied, could also be compatible with P2.
+func ChromiumLowP1Impact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		// (500 * (1.0 + PriorityHysteresisPercent / 100.0)) - 1
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 549,
+		},
+	}
+}
+
+// ChromiumP2Impact returns cluster impact that is consistent with a P2 bug.
+func ChromiumP2Impact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 300,
+		},
+	}
+}
+
+// ChromiumHighP3Impact returns cluster impact that is consistent with a P3
+// bug, but if hysteresis is applied, could also be compatible with P2.
+func ChromiumHighP3Impact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		// (100 / (1.0 + PriorityHysteresisPercent / 100.0)) + 1
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 91,
+		},
+	}
+}
+
+// ChromiumP3Impact returns cluster impact that is consistent with a P3 bug.
+func ChromiumP3Impact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 75,
+		},
+	}
+}
+
+// ChromiumHighestNotFiledImpact returns the highest cluster impact
+// that can be consistent with a bug not being filed.
+func ChromiumHighestNotFiledImpact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		// 75 - 1
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 74,
+		},
+	}
+}
+
+// ChromiumP3LowestBeforeClosureImpact returns cluster impact that
+// is the lowest impact that can be compatible with a P3 bug,
+// after including hysteresis.
+func ChromiumP3LowestBeforeClosureImpact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		// (50 / (1.0 + PriorityHysteresisPercent / 100.0)) + 1
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay: 46,
+		},
+	}
+}
+
+// ChromiumClosureImpact returns cluster impact that is consistent with a
+// closed (verified) bug.
+func ChromiumClosureImpact() *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{}
+}
+
+// ChromiumTestIssuePriority returns the priority of an issue, assuming
+// it has been created consistent with ChromiumTestConfig.
+func ChromiumTestIssuePriority(issue *mpb.Issue) string {
+	for _, fv := range issue.FieldValues {
+		if fv.Field == ChromiumTestPriorityField {
+			return fv.Value
+		}
+	}
+	return ""
+}
diff --git a/analysis/internal/bugs/monorail/testdata.go b/analysis/internal/bugs/monorail/testdata.go
new file mode 100644
index 0000000..930f7c7
--- /dev/null
+++ b/analysis/internal/bugs/monorail/testdata.go
@@ -0,0 +1,174 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package monorail
+
+import (
+	"fmt"
+
+	"github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/testing/assertions"
+	"google.golang.org/protobuf/proto"
+
+	mpb "go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+)
+
+// IssueData is a representation of all data stored for an issue, used by
+// FakeIssuesClient.
+type IssueData struct {
+	Issue    *mpb.Issue
+	Comments []*mpb.Comment
+	// NotifyCount is the number of times a notification has been generated
+	// for the issue.
+	NotifyCount int
+}
+
+// FakeIssuesSystem stores the state of bugs for a fake implementation of monorail.
+type FakeIssuesStore struct {
+	Issues []*IssueData
+	// Resource names of valid components.
+	// E.g. projects/chromium/componentDefs/Blink>Workers.
+	ComponentNames    []string
+	NextID            int
+	PriorityFieldName string
+}
+
+// NewIssueData creates new monorail issue data for testing.
+func NewIssueData(uniqifier int) *IssueData {
+	result := &IssueData{}
+	result.Issue = NewIssue(uniqifier)
+	result.Comments = []*mpb.Comment{
+		NewComment(result.Issue.Name, 1),
+	}
+	result.NotifyCount = 0
+	return result
+}
+
+// NewIssue returns a new monorail issue proto for testing.
+func NewIssue(uniqifier int) *mpb.Issue {
+	return &mpb.Issue{
+		Name:    fmt.Sprintf("projects/monorailproject/issues/%v", uniqifier),
+		Summary: fmt.Sprintf("This is the summary of bug %v.", uniqifier),
+		State:   mpb.IssueContentState_ACTIVE,
+		Status: &mpb.Issue_StatusValue{
+			Status: UntriagedStatus,
+		},
+		Reporter: "user@chromium.org",
+		FieldValues: []*mpb.FieldValue{
+			{
+				Field: ChromiumTestTypeField,
+				Value: "Bug",
+			},
+			{
+				Field: ChromiumTestPriorityField,
+				Value: "1",
+			},
+		},
+	}
+}
+
+// NewComment returns a new monorail comment proto for testing.
+func NewComment(issueName string, number int) *mpb.Comment {
+	return &mpb.Comment{
+		Name:      fmt.Sprintf("%s/comment/%v", issueName, number),
+		State:     mpb.IssueContentState_ACTIVE,
+		Type:      mpb.Comment_DESCRIPTION,
+		Content:   "Issue Description.",
+		Commenter: "user@chromium.org",
+	}
+}
+
+// CopyIssuesStore performs a deep copy of the given FakeIssuesStore.
+func CopyIssuesStore(s *FakeIssuesStore) *FakeIssuesStore {
+	var issues []*IssueData
+	for _, iss := range s.Issues {
+		issues = append(issues, CopyIssueData(iss))
+	}
+	return &FakeIssuesStore{
+		Issues:            issues,
+		ComponentNames:    append([]string{}, s.ComponentNames...),
+		NextID:            s.NextID,
+		PriorityFieldName: s.PriorityFieldName,
+	}
+}
+
+// CopyIssuesStore performs a deep copy of the given IssueData.
+func CopyIssueData(d *IssueData) *IssueData {
+	return &IssueData{
+		Issue:       CopyIssue(d.Issue),
+		Comments:    CopyComments(d.Comments),
+		NotifyCount: d.NotifyCount,
+	}
+}
+
+// CopyIssue performs a deep copy of the given Issue.
+func CopyIssue(issue *mpb.Issue) *mpb.Issue {
+	result := &mpb.Issue{}
+	proto.Merge(result, issue)
+	return result
+}
+
+// CopyComments performs a deep copy of the given Comment.
+func CopyComments(comments []*mpb.Comment) []*mpb.Comment {
+	var result []*mpb.Comment
+	for _, c := range comments {
+		copy := &mpb.Comment{}
+		proto.Merge(copy, c)
+		result = append(result, copy)
+	}
+	return result
+}
+
+// ShouldResembleProto asserts that given two FakeIssuesStores contain equivalent
+// issues (including comments) and NextID.
+func ShouldResembleIssuesStore(actual interface{}, expected ...interface{}) string {
+	if len(expected) != 1 {
+		return fmt.Sprintf("ShouldResembleIssuesStore expects 1 value, got %d", len(expected))
+	}
+	exp := expected[0]
+
+	as, ok := actual.(*FakeIssuesStore)
+	if !ok {
+		return "ShouldResembleIssuesStore is expecting both arguments to be a FakeIssuesStore"
+	}
+	es, ok := exp.(*FakeIssuesStore)
+	if !ok {
+		return "ShouldResembleIssuesStore is expecting both arguments to be a FakeIssuesStore"
+	}
+	if err := convey.ShouldHaveLength(as.Issues, len(es.Issues)); err != "" {
+		return fmt.Sprintf("issues: %s", err)
+	}
+	for i, aIssue := range as.Issues {
+		eIssue := es.Issues[i]
+		if err := assertions.ShouldResembleProto(aIssue.Issue, eIssue.Issue); err != "" {
+			return fmt.Sprintf("issue #%v: %s", i, err)
+		}
+		if err := assertions.ShouldResembleProto(aIssue.Comments, eIssue.Comments); err != "" {
+			return fmt.Sprintf("issue #%v: %s", i, err)
+		}
+		if aIssue.NotifyCount != eIssue.NotifyCount {
+			return fmt.Sprintf("issue #%v notification count: got %v, want %v", i, aIssue.NotifyCount, eIssue.NotifyCount)
+		}
+	}
+	if err := convey.ShouldEqual(as.NextID, es.NextID); err != "" {
+		return fmt.Sprintf("nextID: %s", err)
+	}
+	if err := convey.ShouldResemble(as.ComponentNames, es.ComponentNames); err != "" {
+		return fmt.Sprintf("components: %s", err)
+	}
+	if err := convey.ShouldResemble(as.PriorityFieldName, es.PriorityFieldName); err != "" {
+		return fmt.Sprintf("priorityFieldName: %s", err)
+	}
+	return ""
+}
diff --git a/analysis/internal/bugs/thresholding.go b/analysis/internal/bugs/thresholding.go
new file mode 100644
index 0000000..d10b4ba
--- /dev/null
+++ b/analysis/internal/bugs/thresholding.go
@@ -0,0 +1,251 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bugs
+
+import (
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// InflateThreshold inflates or deflates impact thresholds by the given factor.
+// This method is provided to help implement hysteresis. inflationPercent can
+// be positive or negative (or zero), and is interpreted as follows:
+// - If inflationPercent is positive, the new threshold is (threshold * (1 + (inflationPercent/100)))
+// - If inflationPercent is negative, the new threshold used is (threshold / (1 + (-inflationPercent/100))
+// i.e. inflationPercent of +100 would result in a threshold that is 200% the
+// original threshold being used, inflationPercent of -100 would result in a
+// threshold that is 50% of the original.
+// To avoid unintended effects such as 1 being inflated down to 0, inflation
+// can never make a zero number non-zero or a non-zero number zero.
+func InflateThreshold(t *configpb.ImpactThreshold, inflationPercent int64) *configpb.ImpactThreshold {
+	return &configpb.ImpactThreshold{
+		CriticalFailuresExonerated: inflateMetricThreshold(t.CriticalFailuresExonerated, inflationPercent),
+		PresubmitRunsFailed:        inflateMetricThreshold(t.PresubmitRunsFailed, inflationPercent),
+		TestResultsFailed:          inflateMetricThreshold(t.TestResultsFailed, inflationPercent),
+		TestRunsFailed:             inflateMetricThreshold(t.TestRunsFailed, inflationPercent),
+	}
+}
+
+func inflateMetricThreshold(t *configpb.MetricThreshold, inflationPercent int64) *configpb.MetricThreshold {
+	if t == nil {
+		// No thresholds specified for metric.
+		return nil
+	}
+	return &configpb.MetricThreshold{
+		OneDay:   inflateSingleThreshold(t.OneDay, inflationPercent),
+		ThreeDay: inflateSingleThreshold(t.ThreeDay, inflationPercent),
+		SevenDay: inflateSingleThreshold(t.SevenDay, inflationPercent),
+	}
+}
+
+func inflateSingleThreshold(threshold *int64, inflationPercent int64) *int64 {
+	if threshold == nil {
+		// No threshold was specified.
+		return nil
+	}
+	thresholdValue := *threshold
+	if thresholdValue == 0 {
+		// Explicitly never change a zero value.
+		return &thresholdValue
+	}
+	if inflationPercent >= 0 {
+		// I.E. +100% doubles the threshold.
+		thresholdValue = (thresholdValue * (100 + inflationPercent)) / 100
+	} else {
+		// I.E. -100% halves the threshold.
+		thresholdValue = (thresholdValue * 100) / (100 + -inflationPercent)
+	}
+	// If the result is zero, set it to 1 instead to avoid things like
+	// bug closing thresholds being rounded down to zero failures, and thus
+	// bugs never being closed.
+	if thresholdValue == 0 {
+		thresholdValue = 1
+	}
+	return &thresholdValue
+}
+
+// MeetsThreshold returns whether the nominal impact of the cluster meets
+// or exceeds the specified threshold.
+func (c *ClusterImpact) MeetsThreshold(t *configpb.ImpactThreshold) bool {
+	if c.CriticalFailuresExonerated.meetsThreshold(t.CriticalFailuresExonerated) {
+		return true
+	}
+	if c.TestResultsFailed.meetsThreshold(t.TestResultsFailed) {
+		return true
+	}
+	if c.TestRunsFailed.meetsThreshold(t.TestRunsFailed) {
+		return true
+	}
+	if c.PresubmitRunsFailed.meetsThreshold(t.PresubmitRunsFailed) {
+		return true
+	}
+	return false
+}
+
+func (m MetricImpact) meetsThreshold(t *configpb.MetricThreshold) bool {
+	if t == nil {
+		t = &configpb.MetricThreshold{}
+	}
+	if meetsThreshold(m.OneDay, t.OneDay) {
+		return true
+	}
+	if meetsThreshold(m.ThreeDay, t.ThreeDay) {
+		return true
+	}
+	if meetsThreshold(m.SevenDay, t.SevenDay) {
+		return true
+	}
+	return false
+}
+
+// meetsThreshold tests whether value exceeds the given threshold.
+// If threshold is nil, the threshold is considered "not set"
+// and the method always returns false.
+func meetsThreshold(value int64, threshold *int64) bool {
+	if threshold == nil {
+		return false
+	}
+	thresholdValue := *threshold
+	return value >= thresholdValue
+}
+
+// ThresholdExplanation describes a threshold which was evaluated on
+// a cluster's impact.
+type ThresholdExplanation struct {
+	// A human-readable explanation of the metric.
+	Metric string
+	// The number of days the metric value was measured over.
+	TimescaleDays int
+	// The threshold value of the metric.
+	Threshold int64
+}
+
+// ExplainThresholdMet provides an explanation of why cluster impact would
+// not have met the given priority threshold. As the overall threshold is an
+// 'OR' combination of its underlying thresholds, this returns a list of all
+// thresholds which would not have been met by the cluster's impact.
+func ExplainThresholdNotMet(threshold *configpb.ImpactThreshold) []ThresholdExplanation {
+	var results []ThresholdExplanation
+	results = append(results, explainMetricCriteriaNotMet("Presubmit-Blocking Failures Exonerated", threshold.CriticalFailuresExonerated)...)
+	results = append(results, explainMetricCriteriaNotMet("Presubmit Runs Failed", threshold.PresubmitRunsFailed)...)
+	results = append(results, explainMetricCriteriaNotMet("Test Runs Failed", threshold.TestRunsFailed)...)
+	results = append(results, explainMetricCriteriaNotMet("Test Results Failed", threshold.TestResultsFailed)...)
+	return results
+}
+
+func explainMetricCriteriaNotMet(metric string, threshold *configpb.MetricThreshold) []ThresholdExplanation {
+	if threshold == nil {
+		return nil
+	}
+	var results []ThresholdExplanation
+	if threshold.OneDay != nil {
+		results = append(results, ThresholdExplanation{
+			Metric:        metric,
+			TimescaleDays: 1,
+			Threshold:     *threshold.OneDay,
+		})
+	}
+	if threshold.ThreeDay != nil {
+		results = append(results, ThresholdExplanation{
+			Metric:        metric,
+			TimescaleDays: 3,
+			Threshold:     *threshold.ThreeDay,
+		})
+	}
+	if threshold.SevenDay != nil {
+		results = append(results, ThresholdExplanation{
+			Metric:        metric,
+			TimescaleDays: 7,
+			Threshold:     *threshold.SevenDay,
+		})
+	}
+	return results
+}
+
+// ExplainThresholdMet provides an explanation of why the given cluster impact
+// met the given priority threshold. As the overall threshold is an 'OR' combination of
+// its underlying thresholds, this returns an example of a threshold which a metric
+// value exceeded.
+func (c *ClusterImpact) ExplainThresholdMet(threshold *configpb.ImpactThreshold) ThresholdExplanation {
+	explanation := explainMetricThresholdMet("Presubmit-Blocking Failures Exonerated", c.CriticalFailuresExonerated, threshold.CriticalFailuresExonerated)
+	if explanation != nil {
+		return *explanation
+	}
+	explanation = explainMetricThresholdMet("Presubmit Runs Failed", c.PresubmitRunsFailed, threshold.PresubmitRunsFailed)
+	if explanation != nil {
+		return *explanation
+	}
+	explanation = explainMetricThresholdMet("Test Runs Failed", c.TestRunsFailed, threshold.TestRunsFailed)
+	if explanation != nil {
+		return *explanation
+	}
+	explanation = explainMetricThresholdMet("Test Results Failed", c.TestResultsFailed, threshold.TestResultsFailed)
+	if explanation != nil {
+		return *explanation
+	}
+	// This should not occur, unless the threshold was not met.
+	return ThresholdExplanation{}
+}
+
+func explainMetricThresholdMet(metric string, impact MetricImpact, threshold *configpb.MetricThreshold) *ThresholdExplanation {
+	if threshold == nil {
+		return nil
+	}
+	if threshold.OneDay != nil && impact.OneDay >= *threshold.OneDay {
+		return &ThresholdExplanation{
+			Metric:        metric,
+			TimescaleDays: 1,
+			Threshold:     *threshold.OneDay,
+		}
+	}
+	if threshold.ThreeDay != nil && impact.ThreeDay >= *threshold.ThreeDay {
+		return &ThresholdExplanation{
+			Metric:        metric,
+			TimescaleDays: 3,
+			Threshold:     *threshold.ThreeDay,
+		}
+	}
+	if threshold.SevenDay != nil && impact.SevenDay >= *threshold.SevenDay {
+		return &ThresholdExplanation{
+			Metric:        metric,
+			TimescaleDays: 7,
+			Threshold:     *threshold.SevenDay,
+		}
+	}
+	return nil
+}
+
+// MergeThresholdMetExplanations merges multiple explanations for why thresholds
+// were met into a minimal list, that removes redundant explanations.
+func MergeThresholdMetExplanations(explanations []ThresholdExplanation) []ThresholdExplanation {
+	var results []ThresholdExplanation
+	for _, exp := range explanations {
+		var merged bool
+		for i, otherExp := range results {
+			if otherExp.Metric == exp.Metric && otherExp.TimescaleDays == exp.TimescaleDays {
+				threshold := otherExp.Threshold
+				if exp.Threshold > threshold {
+					threshold = exp.Threshold
+				}
+				results[i].Threshold = threshold
+				merged = true
+				break
+			}
+		}
+		if !merged {
+			results = append(results, exp)
+		}
+	}
+	return results
+}
diff --git a/analysis/internal/bugs/thresholding_test.go b/analysis/internal/bugs/thresholding_test.go
new file mode 100644
index 0000000..bc4fa70
--- /dev/null
+++ b/analysis/internal/bugs/thresholding_test.go
@@ -0,0 +1,278 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package bugs
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"google.golang.org/protobuf/proto"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+func TestThresholding(t *testing.T) {
+	t.Parallel()
+
+	Convey("With Cluster", t, func() {
+		cl := &ClusterImpact{
+			CriticalFailuresExonerated: MetricImpact{
+				OneDay:   60,
+				ThreeDay: 180,
+				SevenDay: 420,
+			},
+			TestResultsFailed: MetricImpact{
+				OneDay:   100,
+				ThreeDay: 300,
+				SevenDay: 700,
+			},
+			TestRunsFailed: MetricImpact{
+				OneDay:   30,
+				ThreeDay: 90,
+				SevenDay: 210,
+			},
+			PresubmitRunsFailed: MetricImpact{
+				OneDay:   3,
+				ThreeDay: 9,
+				SevenDay: 21,
+			},
+		}
+		Convey("MeetsThreshold", func() {
+			t := &configpb.ImpactThreshold{}
+			Convey("No cluster meets empty threshold", func() {
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("Critical failures exonerated thresholding", func() {
+				t.CriticalFailuresExonerated = &configpb.MetricThreshold{OneDay: proto.Int64(60)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.CriticalFailuresExonerated = &configpb.MetricThreshold{OneDay: proto.Int64(61)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("Test results failed thresholding", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(101)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("Test runs failed thresholding", func() {
+				t.TestRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(30)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.TestRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(31)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("Presubmit runs failed thresholding", func() {
+				t.PresubmitRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(3)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.PresubmitRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(4)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("One day threshold", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(101)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("Three day threshold", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{ThreeDay: proto.Int64(300)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.TestResultsFailed = &configpb.MetricThreshold{ThreeDay: proto.Int64(301)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+			Convey("Seven day threshold", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(700)}
+				So(cl.MeetsThreshold(t), ShouldBeTrue)
+
+				t.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(701)}
+				So(cl.MeetsThreshold(t), ShouldBeFalse)
+			})
+		})
+		Convey("InflateThreshold", func() {
+			t := &configpb.ImpactThreshold{}
+			Convey("Empty threshold", func() {
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{})
+			})
+			Convey("Critical test failures exonerated", func() {
+				t.CriticalFailuresExonerated = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					CriticalFailuresExonerated: &configpb.MetricThreshold{OneDay: proto.Int64(115)},
+				})
+			})
+			Convey("Test results failed", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{OneDay: proto.Int64(115)},
+				})
+			})
+			Convey("Test runs failed", func() {
+				t.TestRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					TestRunsFailed: &configpb.MetricThreshold{OneDay: proto.Int64(115)},
+				})
+			})
+			Convey("Presubmit runs failed", func() {
+				t.PresubmitRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					PresubmitRunsFailed: &configpb.MetricThreshold{OneDay: proto.Int64(115)},
+				})
+			})
+			Convey("One day threshold", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{OneDay: proto.Int64(115)},
+				})
+			})
+			Convey("Three day threshold", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{ThreeDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{ThreeDay: proto.Int64(115)},
+				})
+			})
+			Convey("Seven day threshold", func() {
+				t.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(100)}
+				result := InflateThreshold(t, 15)
+				So(result, ShouldResembleProto, &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{SevenDay: proto.Int64(115)},
+				})
+			})
+		})
+		Convey("ExplainThresholdMet", func() {
+			t := &configpb.ImpactThreshold{
+				TestResultsFailed: &configpb.MetricThreshold{
+					OneDay:   proto.Int64(101), // Not met.
+					ThreeDay: proto.Int64(299), // Met.
+					SevenDay: proto.Int64(699), // Met.
+				},
+			}
+			explanation := cl.ExplainThresholdMet(t)
+			So(explanation, ShouldResemble, ThresholdExplanation{
+				Metric:        "Test Results Failed",
+				TimescaleDays: 3,
+				Threshold:     299,
+			})
+		})
+		Convey("ExplainThresholdNotMet", func() {
+			t := &configpb.ImpactThreshold{
+				CriticalFailuresExonerated: &configpb.MetricThreshold{
+					OneDay: proto.Int64(61), // Not met.
+				},
+				TestResultsFailed: &configpb.MetricThreshold{
+					OneDay: proto.Int64(101), // Not met.
+				},
+				TestRunsFailed: &configpb.MetricThreshold{
+					ThreeDay: proto.Int64(301), // Not met.
+				},
+				PresubmitRunsFailed: &configpb.MetricThreshold{
+					SevenDay: proto.Int64(701), // Not met.
+				},
+			}
+			explanation := ExplainThresholdNotMet(t)
+			So(explanation, ShouldResemble, []ThresholdExplanation{
+				{
+					Metric:        "Presubmit-Blocking Failures Exonerated",
+					TimescaleDays: 1,
+					Threshold:     61,
+				},
+				{
+					Metric:        "Presubmit Runs Failed",
+					TimescaleDays: 7,
+					Threshold:     701,
+				},
+				{
+					Metric:        "Test Runs Failed",
+					TimescaleDays: 3,
+					Threshold:     301,
+				},
+				{
+					Metric:        "Test Results Failed",
+					TimescaleDays: 1,
+					Threshold:     101,
+				},
+			})
+		})
+		Convey("MergeThresholdMetExplanations", func() {
+			input := []ThresholdExplanation{
+				{
+					Metric:        "Presubmit Runs Failed",
+					TimescaleDays: 7,
+					Threshold:     20,
+				},
+				{
+					Metric:        "Test Runs Failed",
+					TimescaleDays: 3,
+					Threshold:     100,
+				},
+				{
+					Metric:        "Presubmit Runs Failed",
+					TimescaleDays: 7,
+					Threshold:     10,
+				},
+				{
+					Metric:        "Test Runs Failed",
+					TimescaleDays: 3,
+					Threshold:     200,
+				},
+				{
+					Metric:        "Test Runs Failed",
+					TimescaleDays: 7,
+					Threshold:     700,
+				},
+			}
+			result := MergeThresholdMetExplanations(input)
+			So(result, ShouldResemble, []ThresholdExplanation{
+				{
+					Metric:        "Presubmit Runs Failed",
+					TimescaleDays: 7,
+					Threshold:     20,
+				},
+				{
+					Metric:        "Test Runs Failed",
+					TimescaleDays: 3,
+					Threshold:     200,
+				},
+				{
+					Metric:        "Test Runs Failed",
+					TimescaleDays: 7,
+					Threshold:     700,
+				},
+			})
+		})
+	})
+	Convey("Zero value not inflated", t, func() {
+		input := int64(0)
+		output := inflateSingleThreshold(&input, 200)
+		So(output, ShouldNotBeNil)
+		So(*output, ShouldEqual, 0)
+	})
+	Convey("Non-zero value should not be inflated to zero", t, func() {
+		input := int64(1)
+		output := inflateSingleThreshold(&input, -200)
+		So(output, ShouldNotBeNil)
+		So(*output, ShouldNotEqual, 0)
+	})
+}
diff --git a/analysis/internal/bugs/updater/cron.go b/analysis/internal/bugs/updater/cron.go
new file mode 100644
index 0000000..d4de61b
--- /dev/null
+++ b/analysis/internal/bugs/updater/cron.go
@@ -0,0 +1,227 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package updater
+
+import (
+	"context"
+	"sync"
+	"time"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/sync/parallel"
+	"go.chromium.org/luci/common/tsmon"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/common/tsmon/types"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/bugs/monorail"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+)
+
+var (
+	// statusGauge reports the status of the bug updater job.
+	// Reports either "success" or "failure".
+	statusGauge = metric.NewString("weetbix/bug_updater/status",
+		"Whether automatic bug updates are succeeding, by LUCI Project.",
+		nil,
+		// The LUCI project.
+		field.String("project"),
+	)
+
+	durationGauge = metric.NewFloat("weetbix/bug_updater/duration",
+		"How long it is taking to update bugs, by LUCI Project.",
+		&types.MetricMetadata{
+			Units: types.Seconds,
+		},
+		// The LUCI project.
+		field.String("project"))
+)
+
+// AnalysisClient is an interface for building and accessing cluster analysis.
+type AnalysisClient interface {
+	// RebuildAnalysis rebuilds analysis from the latest clustered test
+	// results.
+	RebuildAnalysis(ctx context.Context, project string) error
+	// ReadImpactfulClusters reads analysis for clusters matching the
+	// specified criteria.
+	ReadImpactfulClusters(ctx context.Context, opts analysis.ImpactfulClusterReadOptions) ([]*analysis.Cluster, error)
+	// PurgeStaleRows purges stale clustered failure rows
+	// from the table.
+	PurgeStaleRows(ctx context.Context, luciProject string) error
+}
+
+func init() {
+	// Register metrics as global metrics, which has the effort of
+	// resetting them after every flush.
+	tsmon.RegisterGlobalCallback(func(ctx context.Context) {
+		// Do nothing -- the metrics will be populated by the cron
+		// job itself and does not need to be triggered externally.
+	}, statusGauge, durationGauge)
+}
+
+// workerCount is the number of workers to use to update
+// analysis and bugs for different LUCI Projects concurrently.
+const workerCount = 8
+
+// UpdateAnalysisAndBugs updates BigQuery analysis, and then updates bugs
+// to reflect this analysis.
+// Simulate, if true, avoids any changes being applied to monorail and logs
+// the changes which would be made instead. This must be set when running
+// on developer computers as Weetbix-initiated monorail changes will appear
+// on monorail as the developer themselves rather than the Weetbix service.
+// This leads to bugs errounously being detected as having manual priority
+// changes.
+func UpdateAnalysisAndBugs(ctx context.Context, monorailHost, gcpProject string, simulate, enable bool) (retErr error) {
+	projectCfg, err := config.Projects(ctx)
+	if err != nil {
+		return err
+	}
+
+	statusByProject := &sync.Map{}
+	for project := range projectCfg {
+		// Until each project succeeds, report "failure".
+		statusByProject.Store(project, "failure")
+	}
+	defer func() {
+		statusByProject.Range(func(key, value interface{}) bool {
+			project := key.(string)
+			status := value.(string)
+			statusGauge.Set(ctx, status, project)
+			return true // continue iteration
+		})
+	}()
+
+	mc, err := monorail.NewClient(ctx, monorailHost)
+	if err != nil {
+		return err
+	}
+
+	ac, err := analysis.NewClient(ctx, gcpProject)
+	if err != nil {
+		return err
+	}
+	defer func() {
+		if err := ac.Close(); err != nil && retErr == nil {
+			retErr = errors.Annotate(err, "closing analysis client").Err()
+		}
+	}()
+
+	projectsWithDataset, err := ac.ProjectsWithDataset(ctx)
+	if err != nil {
+		return errors.Annotate(err, "querying projects with dataset").Err()
+	}
+
+	taskGenerator := func(c chan<- func() error) {
+		for project := range projectCfg {
+			if _, ok := projectsWithDataset[project]; !ok {
+				// Dataset not provisioned for project.
+				statusByProject.Store(project, "disabled")
+				continue
+			}
+
+			opts := updateOptions{
+				appID:              gcpProject,
+				project:            project,
+				analysisClient:     ac,
+				monorailClient:     mc,
+				simulateBugUpdates: simulate,
+				enableBugUpdates:   enable,
+				maxBugsFiledPerRun: 1,
+			}
+			// Assign project to local variable to ensure it can be
+			// accessed correctly inside function closures.
+			project := project
+			c <- func() error {
+				// Isolate other projects from bug update errors
+				// in one project.
+				start := time.Now()
+				err := updateAnalysisAndBugsForProject(ctx, opts)
+				if err != nil {
+					err = errors.Annotate(err, "in project %v", project).Err()
+					logging.Errorf(ctx, "Updating analysis and bugs: %s", err)
+				} else {
+					statusByProject.Store(project, "success")
+				}
+				elapsed := time.Since(start)
+				durationGauge.Set(ctx, elapsed.Seconds(), project)
+
+				// Let the cron job succeed even if one of the projects
+				// is failing. Cron job should only fail if something
+				// catastrophic happens (e.g. such that metrics may
+				// fail to be reported).
+				return nil
+			}
+		}
+	}
+
+	return parallel.WorkPool(workerCount, taskGenerator)
+}
+
+type updateOptions struct {
+	appID              string
+	project            string
+	analysisClient     AnalysisClient
+	monorailClient     *monorail.Client
+	enableBugUpdates   bool
+	simulateBugUpdates bool
+	maxBugsFiledPerRun int
+}
+
+// updateAnalysisAndBugsForProject updates BigQuery analysis, and
+// Weetbix-managed bugs for a particular LUCI project.
+func updateAnalysisAndBugsForProject(ctx context.Context, opts updateOptions) error {
+	// Capture the current state of re-clustering before running analysis.
+	// This will reflect how up-to-date our analysis is when it completes.
+	progress, err := runs.ReadReclusteringProgress(ctx, opts.project)
+	if err != nil {
+		return errors.Annotate(err, "read re-clustering progress").Err()
+	}
+
+	projectCfg, err := compiledcfg.Project(ctx, opts.project, progress.Next.ConfigVersion)
+	if err != nil {
+		return errors.Annotate(err, "read project config").Err()
+	}
+
+	if err := opts.analysisClient.RebuildAnalysis(ctx, opts.project); err != nil {
+		return errors.Annotate(err, "update cluster summary analysis").Err()
+	}
+	if opts.enableBugUpdates {
+		mgrs := make(map[string]BugManager)
+
+		mbm, err := monorail.NewBugManager(opts.monorailClient, opts.appID, opts.project, projectCfg.Config)
+		if err != nil {
+			return errors.Annotate(err, "create monorail bug manager").Err()
+		}
+
+		mbm.Simulate = opts.simulateBugUpdates
+		mgrs[bugs.MonorailSystem] = mbm
+
+		bu := NewBugUpdater(opts.project, mgrs, opts.analysisClient, projectCfg)
+		bu.MaxBugsFiledPerRun = opts.maxBugsFiledPerRun
+		if err := bu.Run(ctx, progress); err != nil {
+			return errors.Annotate(err, "update bugs").Err()
+		}
+	}
+	// Do last, as this failing should not block bug updates.
+	if err := opts.analysisClient.PurgeStaleRows(ctx, opts.project); err != nil {
+		return errors.Annotate(err, "purge stale rows").Err()
+	}
+	return nil
+}
diff --git a/analysis/internal/bugs/updater/impact.go b/analysis/internal/bugs/updater/impact.go
new file mode 100644
index 0000000..094c712
--- /dev/null
+++ b/analysis/internal/bugs/updater/impact.go
@@ -0,0 +1,68 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package updater
+
+import (
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/bugs"
+)
+
+// ExtractResidualImpact extracts the residual impact from a
+// cluster. For suggested clusters, residual impact
+// is the impact of the cluster after failures that are already
+// part of a bug cluster are removed.
+func ExtractResidualImpact(c *analysis.Cluster) *bugs.ClusterImpact {
+	return &bugs.ClusterImpact{
+		CriticalFailuresExonerated: bugs.MetricImpact{
+			OneDay:   c.CriticalFailuresExonerated1d.Residual,
+			ThreeDay: c.CriticalFailuresExonerated3d.Residual,
+			SevenDay: c.CriticalFailuresExonerated7d.Residual,
+		},
+		TestResultsFailed: bugs.MetricImpact{
+			OneDay:   c.Failures1d.Residual,
+			ThreeDay: c.Failures3d.Residual,
+			SevenDay: c.Failures7d.Residual,
+		},
+		TestRunsFailed: bugs.MetricImpact{
+			OneDay:   c.TestRunFails1d.Residual,
+			ThreeDay: c.TestRunFails3d.Residual,
+			SevenDay: c.TestRunFails7d.Residual,
+		},
+		PresubmitRunsFailed: bugs.MetricImpact{
+			OneDay:   c.PresubmitRejects1d.Residual,
+			ThreeDay: c.PresubmitRejects3d.Residual,
+			SevenDay: c.PresubmitRejects7d.Residual,
+		},
+	}
+}
+
+// SetResidualImpact sets the residual impact on a cluster summary.
+func SetResidualImpact(cs *analysis.Cluster, impact *bugs.ClusterImpact) {
+	cs.CriticalFailuresExonerated1d.Residual = impact.CriticalFailuresExonerated.OneDay
+	cs.CriticalFailuresExonerated3d.Residual = impact.CriticalFailuresExonerated.ThreeDay
+	cs.CriticalFailuresExonerated7d.Residual = impact.CriticalFailuresExonerated.SevenDay
+
+	cs.Failures1d.Residual = impact.TestResultsFailed.OneDay
+	cs.Failures3d.Residual = impact.TestResultsFailed.ThreeDay
+	cs.Failures7d.Residual = impact.TestResultsFailed.SevenDay
+
+	cs.TestRunFails1d.Residual = impact.TestRunsFailed.OneDay
+	cs.TestRunFails3d.Residual = impact.TestRunsFailed.ThreeDay
+	cs.TestRunFails7d.Residual = impact.TestRunsFailed.SevenDay
+
+	cs.PresubmitRejects1d.Residual = impact.PresubmitRunsFailed.OneDay
+	cs.PresubmitRejects3d.Residual = impact.PresubmitRunsFailed.ThreeDay
+	cs.PresubmitRejects7d.Residual = impact.PresubmitRunsFailed.SevenDay
+}
diff --git a/analysis/internal/bugs/updater/main_test.go b/analysis/internal/bugs/updater/main_test.go
new file mode 100644
index 0000000..233a248
--- /dev/null
+++ b/analysis/internal/bugs/updater/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package updater
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/bugs/updater/updater.go b/analysis/internal/bugs/updater/updater.go
new file mode 100644
index 0000000..324104a
--- /dev/null
+++ b/analysis/internal/bugs/updater/updater.go
@@ -0,0 +1,726 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package updater
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"sort"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// testnameThresholdInflationPercent is the percentage factor by which
+// the bug filing threshold is inflated when applied to test-name clusters.
+// This is to bias bug-filing towards failure reason clusters, which are
+// seen as generally better scoped and more actionable (because they
+// focus on one reason for the test failing.)
+//
+// The value of 34% was selected as it is sufficient to inflate any threshold
+// values which are a '3' (e.g. CV runs rejected) to a '4'. Otherwise integer
+// discretization of the statistics would cancel out any intended bias.
+//
+// If changing this value, please also update the comment in
+// project_config.proto.
+const testnameThresholdInflationPercent = 34
+
+// mergeIntoCycleErr is the error returned if a cycle is detected in a bug's
+// merged-into graph when handling a bug marked as duplicate.
+var mergeIntoCycleErr = errors.New("a cycle was detected in the bug merged-into graph")
+
+// mergeIntoCycleMessage is the message posted on bugs when Weetbix cannot
+// deal with a bug marked as the duplicate of another.
+const mergeIntoCycleMessage = "Weetbix cannot merge the failure association" +
+	" rule for this bug into the rule for the merged-into bug, because a" +
+	" cycle was detected in the bug merged-into graph. Please manually" +
+	" resolve the cycle, or update rules manually and archive the rule" +
+	" for this bug."
+
+// BugManager implements bug creation and bug updates for a bug-tracking
+// system. The BugManager determines bug content and priority given a
+// cluster.
+type BugManager interface {
+	// Create creates a new bug for the given request, returning its name,
+	// or any encountered error.
+	Create(ctx context.Context, cluster *bugs.CreateRequest) (string, error)
+	// Update updates the specified list of bugs.
+	Update(ctx context.Context, bugs []bugs.BugUpdateRequest) ([]bugs.BugUpdateResponse, error)
+	// GetMergedInto reads the bug the given bug is merged into (if any).
+	// This is to allow step-wise discovery of the canonical bug a bug
+	// is merged into (if it exists and there is no cycle in the bug
+	// merged-into graph).
+	GetMergedInto(ctx context.Context, bug bugs.BugID) (*bugs.BugID, error)
+	// Unduplicate updates a bug to no longer be a duplicate of
+	// another a bug. This provides a way for Weetbix to surface
+	// duplicate bugs it cannot deal with for human intervention.
+	Unduplicate(ctx context.Context, bug bugs.BugID, message string) error
+}
+
+// BugUpdater performs updates to Monorail bugs and failure association
+// rules to keep them in sync with clusters generated by analysis.
+type BugUpdater struct {
+	// project is the LUCI project to act on behalf of.
+	project string
+	// analysisClient provides access to cluster analysis.
+	analysisClient AnalysisClient
+	// managers stores the manager responsible for updating bugs for each
+	// bug tracking system (monorail, buganizer, etc.).
+	managers map[string]BugManager
+	// projectCfg is the snapshot of project configuration to use for
+	// the auto-bug filing run.
+	projectCfg *compiledcfg.ProjectConfig
+	// MaxBugsFiledPerRun is the maximum number of bugs to file each time
+	// BugUpdater runs. This throttles the rate of changes to monorail.
+	MaxBugsFiledPerRun int
+}
+
+// NewBugUpdater initialises a new BugUpdater. The specified impact thresholds are used
+// when determining whether to a file a bug.
+func NewBugUpdater(project string, mgrs map[string]BugManager, ac AnalysisClient, projectCfg *compiledcfg.ProjectConfig) *BugUpdater {
+	return &BugUpdater{
+		project:            project,
+		managers:           mgrs,
+		analysisClient:     ac,
+		projectCfg:         projectCfg,
+		MaxBugsFiledPerRun: 1, // Default value.
+	}
+}
+
+// Run updates files/updates bugs to match high-impact clusters as
+// identified by analysis. Each bug has a corresponding failure association
+// rule.
+// The passed progress should reflect the progress of re-clustering as captured
+// in the latest analysis.
+func (b *BugUpdater) Run(ctx context.Context, progress *runs.ReclusteringProgress) error {
+	// Verify we are not currently reclustering to a new version of
+	// algorithms or project configuration. If we are, we should
+	// suspend bug creation, priority updates and auto-closure
+	// as cluster impact is unreliable.
+	impactValid := b.verifyClusterImpactValid(ctx, progress)
+
+	rules, err := rules.ReadActive(span.Single(ctx), b.project)
+	if err != nil {
+		return errors.Annotate(err, "read active failure association rules").Err()
+	}
+
+	impactByRuleID := make(map[string]*bugs.ClusterImpact)
+	if impactValid {
+		// We want to read analysis for two categories of clusters:
+		// - Bug Clusters: to update the priority of filed bugs.
+		// - Impactful Suggested Clusters: if any suggested clusters have
+		//    reached the threshold to file a new bug for, we want to read
+		//    them, so we can file a bug.
+		clusters, err := b.analysisClient.ReadImpactfulClusters(ctx, analysis.ImpactfulClusterReadOptions{
+			Project:                  b.project,
+			Thresholds:               b.projectCfg.Config.BugFilingThreshold,
+			AlwaysIncludeBugClusters: true,
+		})
+		if err != nil {
+			return errors.Annotate(err, "read impactful clusters").Err()
+		}
+
+		// blockedSourceClusterIDs is the set of source cluster IDs for which
+		// filing new bugs should be suspended.
+		blockedSourceClusterIDs := make(map[string]struct{})
+		for _, r := range rules {
+			if !progress.IncorporatesRulesVersion(r.CreationTime) {
+				// If a bug cluster was recently filed for a source cluster, and
+				// re-clustering and analysis is not yet complete (to move the
+				// impact from the source cluster to the bug cluster), do not file
+				// another bug for the source cluster.
+				// (Of course, if a bug cluster was filed for a source cluster,
+				// but the bug cluster's failure association rule was subsequently
+				// modified (e.g. narrowed), it is allowed to file another bug
+				// if the residual impact justifies it.)
+				blockedSourceClusterIDs[r.SourceCluster.Key()] = struct{}{}
+			}
+		}
+
+		if err := b.fileNewBugs(ctx, clusters, blockedSourceClusterIDs); err != nil {
+			return err
+		}
+
+		for _, cluster := range clusters {
+			if cluster.ClusterID.Algorithm == rulesalgorithm.AlgorithmName {
+				// Use only impact from latest algorithm version.
+				ruleID := cluster.ClusterID.ID
+				impactByRuleID[ruleID] = ExtractResidualImpact(cluster)
+			}
+		}
+	}
+
+	// Prepare bug update requests.
+	bugUpdatesBySystem := make(map[string][]bugs.BugUpdateRequest)
+	for _, r := range rules {
+		var impact *bugs.ClusterImpact
+
+		// Impact is valid if re-clustering and analysis ran on the latest
+		// version of this failure association rule. This avoids bugs getting
+		// erroneous priority changes while impact information is incomplete.
+		ruleImpactValid := impactValid &&
+			progress.IncorporatesRulesVersion(r.PredicateLastUpdated)
+
+		if ruleImpactValid {
+			var ok bool
+			impact, ok = impactByRuleID[r.RuleID]
+			if !ok {
+				// If there is no analysis, this means the cluster is
+				// empty. Use empty impact.
+				impact = &bugs.ClusterImpact{}
+			}
+		}
+		// Else leave impact as nil. Bug-updating code takes this as an
+		// indication valid impact is not available and will not attempt
+		// priority updates/auto-closure.
+
+		bugUpdates := bugUpdatesBySystem[r.BugID.System]
+		bugUpdates = append(bugUpdates, bugs.BugUpdateRequest{
+			Bug:           r.BugID,
+			Impact:        impact,
+			IsManagingBug: r.IsManagingBug,
+			RuleID:        r.RuleID,
+		})
+		bugUpdatesBySystem[r.BugID.System] = bugUpdates
+	}
+
+	var duplicateBugs []bugs.BugID
+	var ruleIDsToArchive []string
+
+	// Perform bug updates.
+	for system, bugsToUpdate := range bugUpdatesBySystem {
+		if system == bugs.BuganizerSystem {
+			// Updating buganizer bugs is currently not supported. This is a
+			// known limitation.
+			continue
+		}
+		manager, ok := b.managers[system]
+		if !ok {
+			logging.Warningf(ctx, "Encountered bug(s) with an unrecognised manager: %q", system)
+			continue
+		}
+		responses, err := manager.Update(ctx, bugsToUpdate)
+		if err != nil {
+			return err
+		}
+
+		for i, rsp := range responses {
+			if rsp.IsDuplicate {
+				duplicateBugs = append(duplicateBugs, bugsToUpdate[i].Bug)
+			} else if rsp.ShouldArchive {
+				ruleIDsToArchive = append(ruleIDsToArchive, bugsToUpdate[i].RuleID)
+			}
+		}
+	}
+
+	// Handle rules which need to be archived because the bugs were:
+	// - Verified for >30 days and managed by Weetbix, OR
+	// - In any closed state for > 30 days and not managed by Weetbix, OR
+	// -
+	if err := b.archiveRules(ctx, ruleIDsToArchive); err != nil {
+		return errors.Annotate(err, "archive rules").Err()
+	}
+
+	// Handle bugs marked as duplicate.
+	for _, bug := range duplicateBugs {
+		err := b.handleDuplicateBug(ctx, bug)
+		if err != nil && err != mergeIntoCycleErr {
+			return errors.Annotate(err, "handling bug (%s) marked as duplicate", bug).Err()
+		}
+		if err == mergeIntoCycleErr {
+			err := b.unduplicateBug(ctx, bug, mergeIntoCycleMessage)
+			if err != nil {
+				return errors.Annotate(err, "unduplicating bug %s", bug).Err()
+			}
+		}
+	}
+
+	return nil
+}
+
+func (b *BugUpdater) verifyClusterImpactValid(ctx context.Context, progress *runs.ReclusteringProgress) bool {
+	if progress.IsReclusteringToNewAlgorithms() {
+		logging.Warningf(ctx, "Auto-bug filing paused for project %s as re-clustering to new algorithms is in progress.", b.project)
+		return false
+	}
+	if progress.IsReclusteringToNewConfig() {
+		logging.Warningf(ctx, "Auto-bug filing paused for project %s as re-clustering to new configuration is in progress.", b.project)
+		return false
+	}
+	if algorithms.AlgorithmsVersion != progress.Next.AlgorithmsVersion {
+		logging.Warningf(ctx, "Auto-bug filing paused for project %s as bug-filing is running mismatched algorithms version %v (want %v).",
+			b.project, algorithms.AlgorithmsVersion, progress.Next.AlgorithmsVersion)
+		return false
+	}
+	if !b.projectCfg.LastUpdated.Equal(progress.Next.ConfigVersion) {
+		logging.Warningf(ctx, "Auto-bug filing paused for project %s as bug-filing is running mismatched config version %v (want %v).",
+			b.project, b.projectCfg.LastUpdated, progress.Next.ConfigVersion)
+		return false
+	}
+	return true
+}
+
+// fileNewBugs files new bugs for suggested clusters whose residual impact
+// exceed the configured bug-filing threshold. Clusters specified in
+// blockedClusterIDs will not have a bug filed. This can be used to
+// suppress bug-filing for suggested clusters that have recently had a
+// bug filed for them and re-clustering is not yet complete.
+func (b *BugUpdater) fileNewBugs(ctx context.Context, clusters []*analysis.Cluster, blockedClusterIDs map[string]struct{}) error {
+	sortByBugFilingPreference(clusters)
+
+	var toCreateBugsFor []*analysis.Cluster
+	for _, cluster := range clusters {
+		if cluster.ClusterID.IsBugCluster() {
+			// Never file another bug for a bug cluster.
+			continue
+		}
+
+		// Was a bug recently filed for this suggested cluster?
+		// We want to avoid race conditions whereby we file multiple bug
+		// clusters for the same suggested cluster, because re-clustering and
+		// re-analysis has not yet run and moved residual impact from the
+		// suggested cluster to the bug cluster.
+		_, ok := blockedClusterIDs[cluster.ClusterID.Key()]
+		if ok {
+			// Do not file a bug.
+			continue
+		}
+
+		// Only file a bug if the residual impact exceeds the threshold.
+		impact := ExtractResidualImpact(cluster)
+		bugFilingThreshold := b.projectCfg.Config.BugFilingThreshold
+		if cluster.ClusterID.IsTestNameCluster() {
+			// Use an inflated threshold for test name clusters to bias
+			// bug creation towards failure reason clusters.
+			bugFilingThreshold =
+				bugs.InflateThreshold(b.projectCfg.Config.BugFilingThreshold,
+					testnameThresholdInflationPercent)
+		}
+		if !impact.MeetsThreshold(bugFilingThreshold) {
+			continue
+		}
+
+		toCreateBugsFor = append(toCreateBugsFor, cluster)
+	}
+
+	// File new bugs.
+	bugsFiled := 0
+	for _, cluster := range toCreateBugsFor {
+		// Throttle how many bugs may be filed each time.
+		if bugsFiled >= b.MaxBugsFiledPerRun {
+			break
+		}
+		created, err := b.createBug(ctx, cluster)
+		if err != nil {
+			return err
+		}
+		if created {
+			bugsFiled++
+		}
+	}
+	return nil
+}
+
+// archiveRules archives the given list of rules.
+func (b *BugUpdater) archiveRules(ctx context.Context, ruleIDs []string) error {
+	if len(ruleIDs) == 0 {
+		return nil
+	}
+	// Limit the number of rules that can be archived at once to stay
+	// well within Spanner mutation limits. The rest will be handled
+	// in the next bug-filing run.
+	if len(ruleIDs) > 100 {
+		ruleIDs = ruleIDs[:100]
+	}
+	f := func(ctx context.Context) error {
+		// Perform atomic read-update of rule.
+		rs, err := rules.ReadMany(ctx, b.project, ruleIDs)
+		if err != nil {
+			return errors.Annotate(err, "read rules to archive").Err()
+		}
+		for _, r := range rs {
+			r.IsActive = false
+			updatePredicate := true
+			if err := rules.Update(ctx, r, updatePredicate, rules.WeetbixSystem); err != nil {
+				// Validation error. Actual save happens upon transaction
+				// commit.
+				return errors.Annotate(err, "update rules").Err()
+			}
+		}
+		return nil
+	}
+	_, err := span.ReadWriteTransaction(ctx, f)
+	return err
+}
+
+// handleDuplicateBug handles a duplicate bug, merging its failure association
+// rule with the bug it is ultimately merged into (creating the rule if it does
+// not exist). The original rule is disabled.
+func (b *BugUpdater) handleDuplicateBug(ctx context.Context, bug bugs.BugID) error {
+	// Chase the bug merged-into graph until we find the sink of the graph.
+	// (The canonical bug of the chain of duplicate bugs.)
+	destBug, err := b.resolveMergedIntoBug(ctx, bug)
+	if err != nil {
+		// E.g. a cycle was found in the graph.
+		return err
+	}
+	f := func(ctx context.Context) error {
+		sourceRule, _, err := readRuleForBugAndProject(ctx, bug, b.project)
+		if err != nil {
+			return errors.Annotate(err, "reading rule for source bug").Err()
+		}
+		if !sourceRule.IsActive {
+			// The source rule is no longer active. This is a race condition
+			// as we only do bug updates for rules that exist at the time
+			// we start bug updates.
+			// An inactive rule does not match any failures so merging the
+			// it into another rule should have no effect anyway.
+			return nil
+		}
+		// Try and read the rule for the bug we are merging into.
+		destinationRule, anyRuleManagingDestBug, err :=
+			readRuleForBugAndProject(ctx, destBug, b.project)
+		if err != nil {
+			return errors.Annotate(err, "reading rule for destination bug").Err()
+		}
+		if destinationRule == nil {
+			// Simply update the source rule to point to the new bug.
+			sourceRule.BugID = destBug
+
+			// Only one rule can manage a bug at a given time.
+			// Even if there is no rule in this project which manages
+			// the destination bug, there could a rule in a different project.
+			if anyRuleManagingDestBug {
+				sourceRule.IsManagingBug = false
+			}
+
+			updatePredicate := false
+			err = rules.Update(ctx, sourceRule, updatePredicate, rules.WeetbixSystem)
+			if err != nil {
+				// Indicates validation error. Should never happen.
+				return err
+			}
+			return nil
+		} else {
+			if destinationRule.IsActive {
+				// Merge the source and destination rules with an "OR".
+				// Note that this is only valid because OR is the operator
+				// with the lowest precedence in our language.
+				// Otherwise we would have to be concerned about inserting
+				// parentheses.
+				destinationRule.RuleDefinition =
+					destinationRule.RuleDefinition + " OR\n" + sourceRule.RuleDefinition
+				if err != nil {
+					return errors.Annotate(err, "merging rules").Err()
+				}
+			} else {
+				// Else: an inactive rule does not match any failures, so we should
+				// use only the rule from the source bug.
+				destinationRule.RuleDefinition = sourceRule.RuleDefinition
+			}
+
+			// Disable the source rule.
+			sourceRule.IsActive = false
+			updatePredicate := true
+			err = rules.Update(ctx, sourceRule, updatePredicate, rules.WeetbixSystem)
+
+			// Update the rule on the destination rule.
+			destinationRule.IsActive = true
+			err = rules.Update(ctx, destinationRule, updatePredicate, rules.WeetbixSystem)
+			return err
+		}
+	}
+	// Update source and destination rules in one transaction, to ensure
+	// consistency.
+	_, err = span.ReadWriteTransaction(ctx, f)
+	return err
+}
+
+// resolveMergedIntoBug resolves the bug the given bug is ultimately merged
+// into.
+func (b *BugUpdater) resolveMergedIntoBug(ctx context.Context, bug bugs.BugID) (bugs.BugID, error) {
+	isResolved := false
+	mergedIntoBug := bug
+	const maxResolutionSteps = 5
+	for i := 0; i < maxResolutionSteps; i++ {
+		system := mergedIntoBug.System
+		if system == bugs.BuganizerSystem {
+			// Resolving the canonical "merged into" bug for bugs in
+			// buganizer is not supported. We'll merge into the first
+			// buganizer bug we see.
+			isResolved = true
+			break
+		}
+		manager, ok := b.managers[system]
+		if !ok {
+			return bugs.BugID{}, fmt.Errorf("encountered unknown bug system: %q", system)
+		}
+		mergedInto, err := manager.GetMergedInto(ctx, mergedIntoBug)
+		if err != nil {
+			return bugs.BugID{}, err
+		}
+		if mergedInto == nil {
+			isResolved = true
+			break
+		} else {
+			mergedIntoBug = *mergedInto
+		}
+	}
+	if !isResolved {
+		return bugs.BugID{}, mergeIntoCycleErr
+	}
+	if mergedIntoBug == bug {
+		// This would normally never occur, but is possible in some
+		// exceptional scenarios like race conditions where a cycle
+		// is broken during the graph traversal, or a bug which
+		// was marked as duplicate but is no longer marked as duplicate
+		// now.
+		return bugs.BugID{}, fmt.Errorf("cannot deduplicate a bug into itself")
+	}
+	return mergedIntoBug, nil
+}
+
+// unduplicateBug attempts to unduplicate a bug, posting the given message
+// on the bug. This allows the system to push back and surface errors
+// when it cannot handle a bug being deduplicated into another and the user
+// needs to manually intervene.
+func (b *BugUpdater) unduplicateBug(ctx context.Context, bug bugs.BugID, message string) error {
+	manager, ok := b.managers[bug.System]
+	if !ok {
+		return fmt.Errorf("encountered unknown bug system: %q", bug.System)
+	}
+	err := manager.Unduplicate(ctx, bug, message)
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// readRuleForBugAndProject reads the failure association rule for the given
+// bug in the given project, if it exists. It additionally returns whether
+// there is any rule in the system that manages the given bug, even if in
+// a different project.
+// If the rule cannot be read, it returns nil.
+func readRuleForBugAndProject(ctx context.Context, bug bugs.BugID, project string) (rule *rules.FailureAssociationRule, anyRuleManaging bool, err error) {
+	rules, err := rules.ReadByBug(ctx, bug)
+	if err != nil {
+		return nil, false, err
+	}
+	rule = nil
+	anyRuleManaging = false
+	for _, r := range rules {
+		if r.IsManagingBug {
+			anyRuleManaging = true
+		}
+		if r.Project == project {
+			rule = r
+		}
+	}
+	return rule, anyRuleManaging, nil
+}
+
+// sortByBugFilingPreference sorts clusters based on our preference
+// to file bugs for these clusters.
+func sortByBugFilingPreference(cs []*analysis.Cluster) {
+	// The current ranking approach prefers filing bugs for clusters with more
+	// impact, with a bias towards reason clusters.
+	//
+	// The order of this ranking is only important where there are
+	// multiple competing clusters which meet the bug-filing threshold.
+	// As bug filing runs relatively often, except in cases of contention,
+	// the first bug to meet the threshold will be filed.
+	sort.Slice(cs, func(i, j int) bool {
+		presubmitRejects := func(cs *analysis.Cluster) analysis.Counts { return cs.PresubmitRejects7d }
+		criticalFailuresExonerated := func(cs *analysis.Cluster) analysis.Counts { return cs.CriticalFailuresExonerated7d }
+		failures := func(cs *analysis.Cluster) analysis.Counts { return cs.Failures7d }
+
+		if equal, less := rankByMetric(cs[i], cs[j], presubmitRejects); !equal {
+			return less
+		}
+		if equal, less := rankByMetric(cs[i], cs[j], criticalFailuresExonerated); !equal {
+			return less
+		}
+		if equal, less := rankByMetric(cs[i], cs[j], failures); !equal {
+			return less
+		}
+		// If all else fails, sort by cluster ID. This is mostly to ensure
+		// the code behaves deterministically when under unit testing.
+		if cs[i].ClusterID.Algorithm != cs[j].ClusterID.Algorithm {
+			return cs[i].ClusterID.Algorithm < cs[j].ClusterID.Algorithm
+		}
+		return cs[i].ClusterID.ID < cs[j].ClusterID.ID
+	})
+}
+
+func rankByMetric(a, b *analysis.Cluster, accessor func(*analysis.Cluster) analysis.Counts) (equal bool, less bool) {
+	valueA := accessor(a).Residual
+	valueB := accessor(b).Residual
+	// If one cluster we are comparing with is a test name cluster,
+	// give the other cluster an impact boost in the comparison, so
+	// that we bias towards filing it (instead of the test name cluster).
+	if b.ClusterID.IsTestNameCluster() {
+		valueA = (valueA * (100 + testnameThresholdInflationPercent)) / 100
+	}
+	if a.ClusterID.IsTestNameCluster() {
+		valueB = (valueB * (100 + testnameThresholdInflationPercent)) / 100
+	}
+	equal = (valueA == valueB)
+	// a less than b in the sort order is defined as a having more impact
+	// than b, so that clusters are sorted in descending impact order.
+	less = (valueA > valueB)
+	return equal, less
+}
+
+// createBug files a new bug for the given suggested cluster,
+// and stores the association from bug to failures through a new
+// failure association rule.
+func (b *BugUpdater) createBug(ctx context.Context, cs *analysis.Cluster) (created bool, err error) {
+	alg, err := algorithms.SuggestingAlgorithm(cs.ClusterID.Algorithm)
+	if err == algorithms.ErrAlgorithmNotExist {
+		// The cluster is for an old algorithm that no longer exists, or
+		// for a new algorithm that is not known by us yet.
+		// Do not file a bug. This is not an error, it is expected during
+		// algorithm version changes.
+		return false, nil
+	}
+
+	summary := clusterSummaryFromAnalysis(cs)
+
+	// Double-check the failure matches the cluster. Generating a
+	// failure association rule that does not match the suggested cluster
+	// could result in indefinite creation of new bugs, as the system
+	// will repeatedly create new failure association rules for the
+	// same suggested cluster.
+	// Mismatches should usually be transient as re-clustering will fix
+	// up any incorrect clustering.
+	if hex.EncodeToString(alg.Cluster(b.projectCfg, &summary.Example)) != cs.ClusterID.ID {
+		return false, errors.New("example failure did not match cluster ID")
+	}
+	rule, err := b.generateFailureAssociationRule(alg, &summary.Example)
+	if err != nil {
+		return false, errors.Annotate(err, "obtain failure association rule").Err()
+	}
+
+	ruleID, err := rules.GenerateID()
+	if err != nil {
+		return false, errors.Annotate(err, "generating rule ID").Err()
+	}
+
+	description, err := alg.ClusterDescription(b.projectCfg, summary)
+	if err != nil {
+		return false, errors.Annotate(err, "prepare bug description").Err()
+	}
+
+	var monorailComponents []string
+	for _, tc := range cs.TopMonorailComponents {
+		// Any monorail component is associated for more than 30% of the
+		// failures in the cluster should be on the filed bug.
+		if tc.Count > ((cs.Failures7d.Nominal * 3) / 10) {
+			monorailComponents = append(monorailComponents, tc.Value)
+		}
+	}
+	request := &bugs.CreateRequest{
+		Description:        description,
+		Impact:             ExtractResidualImpact(cs),
+		MonorailComponents: monorailComponents,
+	}
+
+	// For now, the only issue system supported is monorail.
+	system := bugs.MonorailSystem
+	mgr := b.managers[system]
+	name, err := mgr.Create(ctx, request)
+	if err == bugs.ErrCreateSimulated {
+		// Create did not do anything because it is in simulation mode.
+		// This is expected.
+		return true, nil
+	}
+	if err != nil {
+		return false, errors.Annotate(err, "create issue in %v", system).Err()
+	}
+
+	// Create a failure association rule associating the failures with a bug.
+	r := &rules.FailureAssociationRule{
+		Project:        b.project,
+		RuleID:         ruleID,
+		RuleDefinition: rule,
+		BugID:          bugs.BugID{System: system, ID: name},
+		IsActive:       true,
+		IsManagingBug:  true,
+		SourceCluster:  cs.ClusterID,
+	}
+	create := func(ctx context.Context) error {
+		user := rules.WeetbixSystem
+		return rules.Create(ctx, r, user)
+	}
+	if _, err := span.ReadWriteTransaction(ctx, create); err != nil {
+		return false, errors.Annotate(err, "create bug cluster").Err()
+	}
+
+	return true, nil
+}
+
+func clusterSummaryFromAnalysis(c *analysis.Cluster) *clustering.ClusterSummary {
+	example := clustering.Failure{
+		TestID: c.ExampleTestID(),
+	}
+	if c.ExampleFailureReason.Valid {
+		example.Reason = &pb.FailureReason{PrimaryErrorMessage: c.ExampleFailureReason.StringVal}
+	}
+	// A list of 5 commonly occuring tests are included in bugs created
+	// for failure reason clusters, to improve searchability by test name.
+	var topTests []string
+	for _, tt := range c.TopTestIDs {
+		topTests = append(topTests, tt.Value)
+	}
+	return &clustering.ClusterSummary{
+		Example:  example,
+		TopTests: topTests,
+	}
+}
+
+func (b *BugUpdater) generateFailureAssociationRule(alg algorithms.Algorithm, failure *clustering.Failure) (string, error) {
+	rule := alg.FailureAssociationRule(b.projectCfg, failure)
+
+	// Check the generated rule is valid and matches the failure.
+	// An improperly generated failure association rule could result
+	// in uncontrolled creation of new bugs.
+	expr, err := lang.Parse(rule)
+	if err != nil {
+		return "", errors.Annotate(err, "rule generated by %s did not parse", alg.Name()).Err()
+	}
+	match := expr.Evaluate(failure)
+	if !match {
+		reason := ""
+		if failure.Reason != nil {
+			reason = failure.Reason.PrimaryErrorMessage
+		}
+		return "", fmt.Errorf("rule generated by %s did not match example failure (testID: %q, failureReason: %q)",
+			alg.Name(), failure.TestID, reason)
+	}
+	return rule, nil
+}
diff --git a/analysis/internal/bugs/updater/updater_test.go b/analysis/internal/bugs/updater/updater_test.go
new file mode 100644
index 0000000..08252da
--- /dev/null
+++ b/analysis/internal/bugs/updater/updater_test.go
@@ -0,0 +1,941 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package updater
+
+import (
+	"context"
+	"encoding/hex"
+	"errors"
+	"fmt"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	"go.chromium.org/luci/config/validation"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/bugs/monorail/api_proto"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/bugs/monorail"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestRun(t *testing.T) {
+	Convey("Run bug updates", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx = memory.Use(ctx)
+
+		f := &monorail.FakeIssuesStore{
+			NextID:            100,
+			PriorityFieldName: "projects/chromium/fieldDefs/11",
+			ComponentNames: []string{
+				"projects/chromium/componentDefs/Blink",
+				"projects/chromium/componentDefs/Blink>Layout",
+				"projects/chromium/componentDefs/Blink>Network",
+			},
+		}
+		user := monorail.AutomationUsers[0]
+		mc, err := monorail.NewClient(monorail.UseFakeIssuesClient(ctx, f, user), "myhost")
+		So(err, ShouldBeNil)
+
+		project := "chromium"
+		monorailCfg := monorail.ChromiumTestConfig()
+		thres := &configpb.ImpactThreshold{
+			// Should be more onerous than the "keep-open" thresholds
+			// configured for each individual bug manager.
+			TestResultsFailed: &configpb.MetricThreshold{
+				OneDay:   proto.Int64(100),
+				ThreeDay: proto.Int64(300),
+				SevenDay: proto.Int64(700),
+			},
+		}
+		projectCfg := &configpb.ProjectConfig{
+			Monorail:           monorailCfg,
+			BugFilingThreshold: thres,
+			LastUpdated:        timestamppb.New(time.Date(2030, time.July, 1, 0, 0, 0, 0, time.UTC)),
+		}
+		projectsCfg := map[string]*configpb.ProjectConfig{
+			project: projectCfg,
+		}
+		err = config.SetTestProjectConfig(ctx, projectsCfg)
+		So(err, ShouldBeNil)
+
+		compiledCfg, err := compiledcfg.NewConfig(projectCfg)
+		So(err, ShouldBeNil)
+
+		suggestedClusters := []*analysis.Cluster{
+			makeReasonCluster(compiledCfg, 0),
+			makeReasonCluster(compiledCfg, 1),
+			makeReasonCluster(compiledCfg, 2),
+			makeReasonCluster(compiledCfg, 3),
+		}
+		ac := &fakeAnalysisClient{
+			clusters: suggestedClusters,
+		}
+
+		opts := updateOptions{
+			appID:              "chops-weetbix-test",
+			project:            project,
+			analysisClient:     ac,
+			monorailClient:     mc,
+			enableBugUpdates:   true,
+			maxBugsFiledPerRun: 1,
+		}
+
+		// Unless otherwise specified, assume re-clustering has caught up to
+		// the latest version of algorithms and config.
+		err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
+			runs.NewRun(0).
+				WithProject(project).
+				WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
+				WithConfigVersion(projectCfg.LastUpdated.AsTime()).
+				WithRulesVersion(rules.StartingEpoch).
+				WithCompletedProgress().Build(),
+		})
+		So(err, ShouldBeNil)
+
+		// Mock current time. This is needed to control behaviours like
+		// automatic archiving of rules after 30 days of bug being marked
+		// Closed (Verified).
+		now := time.Date(2055, time.May, 5, 5, 5, 5, 5, time.UTC)
+		ctx, tc := testclock.UseTime(ctx, now)
+
+		Convey("Configuration used for testing is valid", func() {
+			c := validation.Context{Context: context.Background()}
+
+			config.ValidateProjectConfig(&c, projectCfg)
+			So(c.Finalize(), ShouldBeNil)
+		})
+		Convey("With no impactful clusters", func() {
+			err = updateAnalysisAndBugsForProject(ctx, opts)
+			So(err, ShouldBeNil)
+
+			// No failure association rules.
+			rs, err := rules.ReadActive(span.Single(ctx), project)
+			So(err, ShouldBeNil)
+			So(rs, ShouldResemble, []*rules.FailureAssociationRule{})
+
+			// No monorail issues.
+			So(f.Issues, ShouldBeNil)
+		})
+		Convey("With buganizer bugs", func() {
+			rs := []*rules.FailureAssociationRule{
+				rules.NewRule(1).WithProject(project).WithBug(bugs.BugID{
+					System: "buganizer", ID: "12345678",
+				}).Build(),
+			}
+			rules.SetRulesForTesting(ctx, rs)
+
+			// Bug filing should not encounter errors.
+			err = updateAnalysisAndBugsForProject(ctx, opts)
+			So(err, ShouldBeNil)
+		})
+		Convey("With a suggested cluster above impact thresold", func() {
+			sourceClusterID := reasonClusterID(compiledCfg, "Failed to connect to 100.1.1.99.")
+			suggestedClusters[1].ClusterID = sourceClusterID
+			suggestedClusters[1].ExampleFailureReason = bigquery.NullString{StringVal: "Failed to connect to 100.1.1.105.", Valid: true}
+			suggestedClusters[1].TopTestIDs = []analysis.TopCount{
+				{Value: "network-test-1", Count: 10},
+				{Value: "network-test-2", Count: 10},
+			}
+			suggestedClusters[1].Failures7d.Nominal = 100
+			suggestedClusters[1].TopMonorailComponents = []analysis.TopCount{
+				{Value: "Blink>Layout", Count: 40},  // >30% of failures.
+				{Value: "Blink>Network", Count: 31}, // >30% of failures.
+				{Value: "Blink>Other", Count: 4},
+			}
+
+			ignoreRuleID := ""
+			expectCreate := true
+
+			expectedRule := &rules.FailureAssociationRule{
+				Project:         "chromium",
+				RuleDefinition:  `reason LIKE "Failed to connect to %.%.%.%."`,
+				BugID:           bugs.BugID{System: "monorail", ID: "chromium/100"},
+				IsActive:        true,
+				IsManagingBug:   true,
+				SourceCluster:   sourceClusterID,
+				CreationUser:    rules.WeetbixSystem,
+				LastUpdatedUser: rules.WeetbixSystem,
+			}
+
+			expectedBugSummary := "Failed to connect to 100.1.1.105."
+
+			// Expect the bug description to contain the top tests.
+			expectedBugContents := []string{
+				"network-test-1",
+				"network-test-2",
+			}
+
+			test := func() {
+				err = updateAnalysisAndBugsForProject(ctx, opts)
+				So(err, ShouldBeNil)
+
+				rs, err := rules.ReadActive(span.Single(ctx), project)
+				So(err, ShouldBeNil)
+
+				var cleanedRules []*rules.FailureAssociationRule
+				for _, r := range rs {
+					if r.RuleID != ignoreRuleID {
+						cleanedRules = append(cleanedRules, r)
+					}
+				}
+
+				if !expectCreate {
+					So(len(cleanedRules), ShouldEqual, 0)
+					return
+				}
+
+				So(len(cleanedRules), ShouldEqual, 1)
+				rule := cleanedRules[0]
+
+				// Accept whatever bug cluster ID has been generated.
+				So(rule.RuleID, ShouldNotBeEmpty)
+				expectedRule.RuleID = rule.RuleID
+
+				// Accept creation and last updated times, as set by Spanner.
+				So(rule.CreationTime, ShouldNotBeZeroValue)
+				expectedRule.CreationTime = rule.CreationTime
+				So(rule.LastUpdated, ShouldNotBeZeroValue)
+				expectedRule.LastUpdated = rule.LastUpdated
+				So(rule.PredicateLastUpdated, ShouldNotBeZeroValue)
+				expectedRule.PredicateLastUpdated = rule.PredicateLastUpdated
+				So(rule, ShouldResemble, expectedRule)
+
+				So(len(f.Issues), ShouldEqual, 1)
+				So(f.Issues[0].Issue.Name, ShouldEqual, "projects/chromium/issues/100")
+				So(f.Issues[0].Issue.Summary, ShouldContainSubstring, expectedBugSummary)
+				So(f.Issues[0].Issue.Components, ShouldHaveLength, 2)
+				So(f.Issues[0].Issue.Components[0].Component, ShouldEqual, "projects/chromium/componentDefs/Blink>Layout")
+				So(f.Issues[0].Issue.Components[1].Component, ShouldEqual, "projects/chromium/componentDefs/Blink>Network")
+				So(len(f.Issues[0].Comments), ShouldEqual, 2)
+				for _, expectedContent := range expectedBugContents {
+					So(f.Issues[0].Comments[0].Content, ShouldContainSubstring, expectedContent)
+				}
+				// Expect a link to the bug and the rule.
+				So(f.Issues[0].Comments[1].Content, ShouldContainSubstring, "https://chops-weetbix-test.appspot.com/b/chromium/100")
+			}
+
+			Convey("1d unexpected failures", func() {
+				Convey("Reason cluster", func() {
+					Convey("Above thresold", func() {
+						suggestedClusters[1].Failures1d.Residual = 100
+						test()
+
+						// Further updates do nothing.
+						test()
+					})
+					Convey("Below threshold", func() {
+						suggestedClusters[1].Failures1d.Residual = 99
+						expectCreate = false
+						test()
+					})
+				})
+				Convey("Test name cluster", func() {
+					suggestedClusters[1].ClusterID = testIDClusterID(compiledCfg, "ui-test-1")
+					suggestedClusters[1].TopTestIDs = []analysis.TopCount{
+						{Value: "ui-test-1", Count: 10},
+					}
+					expectedRule.RuleDefinition = `test = "ui-test-1"`
+					expectedRule.SourceCluster = suggestedClusters[1].ClusterID
+					expectedBugSummary = "ui-test-1"
+					expectedBugContents = []string{"ui-test-1"}
+
+					// 34% more impact is required for a test name cluster to
+					// be filed, compared to a failure reason cluster.
+					Convey("Above thresold", func() {
+						suggestedClusters[1].Failures1d.Residual = 134
+						test()
+
+						// Further updates do nothing.
+						test()
+					})
+					Convey("Below threshold", func() {
+						suggestedClusters[1].Failures1d.Residual = 133
+						expectCreate = false
+						test()
+					})
+				})
+			})
+			Convey("3d unexpected failures", func() {
+				suggestedClusters[1].Failures3d.Residual = 300
+				test()
+
+				// Further updates do nothing.
+				test()
+			})
+			Convey("7d unexpected failures", func() {
+				suggestedClusters[1].Failures7d.Residual = 700
+				test()
+
+				// Further updates do nothing.
+				test()
+			})
+			Convey("With existing rule filed", func() {
+				suggestedClusters[1].Failures1d.Residual = 100
+
+				createTime := time.Date(2021, time.January, 5, 12, 30, 0, 0, time.UTC)
+				rule := rules.NewRule(1).
+					WithProject(project).
+					WithCreationTime(createTime).
+					WithPredicateLastUpdated(createTime.Add(1 * time.Hour)).
+					WithLastUpdated(createTime.Add(2 * time.Hour)).
+					WithSourceCluster(sourceClusterID).Build()
+				err := rules.SetRulesForTesting(ctx, []*rules.FailureAssociationRule{
+					rule,
+				})
+				So(err, ShouldBeNil)
+				ignoreRuleID = rule.RuleID
+
+				// Initially do not expect a new bug to be filed.
+				expectCreate = false
+				test()
+
+				// Once re-clustering has incorporated the version of rules
+				// that included this new rule, it is OK to file another bug
+				// for the suggested cluster if sufficient impact remains.
+				// This should only happen when the rule definition has been
+				// manually narrowed in some way from the originally filed bug.
+				err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
+					runs.NewRun(0).
+						WithProject(project).
+						WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
+						WithConfigVersion(projectCfg.LastUpdated.AsTime()).
+						WithRulesVersion(createTime).
+						WithCompletedProgress().Build(),
+				})
+				So(err, ShouldBeNil)
+
+				expectCreate = true
+				test()
+			})
+			Convey("With bug updates disabled", func() {
+				suggestedClusters[1].Failures1d.Residual = 100
+
+				opts.enableBugUpdates = false
+
+				expectCreate = false
+				test()
+			})
+			Convey("Without re-clustering caught up to latest algorithms", func() {
+				suggestedClusters[1].Failures1d.Residual = 100
+
+				err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
+					runs.NewRun(0).
+						WithProject(project).
+						WithAlgorithmsVersion(algorithms.AlgorithmsVersion - 1).
+						WithConfigVersion(projectCfg.LastUpdated.AsTime()).
+						WithRulesVersion(rules.StartingEpoch).
+						WithCompletedProgress().Build(),
+				})
+				So(err, ShouldBeNil)
+
+				expectCreate = false
+				test()
+			})
+			Convey("Without re-clustering caught up to latest config", func() {
+				suggestedClusters[1].Failures1d.Residual = 100
+
+				err = runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
+					runs.NewRun(0).
+						WithProject(project).
+						WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
+						WithConfigVersion(projectCfg.LastUpdated.AsTime().Add(-1 * time.Hour)).
+						WithRulesVersion(rules.StartingEpoch).
+						WithCompletedProgress().Build(),
+				})
+				So(err, ShouldBeNil)
+
+				expectCreate = false
+				test()
+			})
+		})
+		Convey("With both failure reason and test name clusters above bug-filing threshold", func() {
+			// Reason cluster above the 3-day failure threshold.
+			suggestedClusters[2] = makeReasonCluster(compiledCfg, 2)
+			suggestedClusters[2].Failures3d.Residual = 400
+			suggestedClusters[2].Failures7d.Residual = 400
+
+			// Test name cluster with 33% more impact.
+			suggestedClusters[1] = makeTestNameCluster(compiledCfg, 3)
+			suggestedClusters[1].Failures3d.Residual = 532
+			suggestedClusters[1].Failures7d.Residual = 532
+
+			// Limit to one bug filed each time, so that
+			// we test change throttling.
+			opts.maxBugsFiledPerRun = 1
+
+			Convey("Reason clusters preferred over test name clusters", func() {
+				// Test name cluster has <34% more impact than the reason
+				// cluster.
+				err = updateAnalysisAndBugsForProject(ctx, opts)
+				So(err, ShouldBeNil)
+
+				// Reason cluster filed.
+				bugClusters, err := rules.ReadActive(span.Single(ctx), project)
+				So(err, ShouldBeNil)
+				So(len(bugClusters), ShouldEqual, 1)
+				So(bugClusters[0].SourceCluster, ShouldResemble, suggestedClusters[2].ClusterID)
+				So(bugClusters[0].SourceCluster.IsFailureReasonCluster(), ShouldBeTrue)
+			})
+			Convey("Test name clusters can be filed if significantly more impact", func() {
+				// Reduce impact of the reason-based cluster so that the
+				// test name cluster has >34% more impact than the reason
+				// cluster.
+				suggestedClusters[2].Failures3d.Residual = 390
+				suggestedClusters[2].Failures7d.Residual = 390
+
+				err = updateAnalysisAndBugsForProject(ctx, opts)
+				So(err, ShouldBeNil)
+
+				// Test name cluster filed first.
+				bugClusters, err := rules.ReadActive(span.Single(ctx), project)
+				So(err, ShouldBeNil)
+				So(len(bugClusters), ShouldEqual, 1)
+				So(bugClusters[0].SourceCluster, ShouldResemble, suggestedClusters[1].ClusterID)
+				So(bugClusters[0].SourceCluster.IsTestNameCluster(), ShouldBeTrue)
+			})
+		})
+		Convey("With multiple suggested clusters above impact thresold", func() {
+			expectBugClusters := func(count int) {
+				bugClusters, err := rules.ReadActive(span.Single(ctx), project)
+				So(err, ShouldBeNil)
+				So(len(bugClusters), ShouldEqual, count)
+				So(len(f.Issues), ShouldEqual, count)
+			}
+			// Use a mix of test name and failure reason clusters for
+			// code path coverage.
+			suggestedClusters[0] = makeTestNameCluster(compiledCfg, 0)
+			suggestedClusters[0].Failures7d.Residual = 940
+			suggestedClusters[1] = makeReasonCluster(compiledCfg, 1)
+			suggestedClusters[1].Failures3d.Residual = 300
+			suggestedClusters[1].Failures7d.Residual = 300
+			suggestedClusters[2] = makeReasonCluster(compiledCfg, 2)
+			suggestedClusters[2].Failures1d.Residual = 200
+			suggestedClusters[2].Failures3d.Residual = 200
+			suggestedClusters[2].Failures7d.Residual = 200
+
+			// Limit to one bug filed each time, so that
+			// we test change throttling.
+			opts.maxBugsFiledPerRun = 1
+
+			err = updateAnalysisAndBugsForProject(ctx, opts)
+			So(err, ShouldBeNil)
+			expectBugClusters(1)
+
+			err = updateAnalysisAndBugsForProject(ctx, opts)
+			So(err, ShouldBeNil)
+			expectBugClusters(2)
+
+			err = updateAnalysisAndBugsForProject(ctx, opts)
+			So(err, ShouldBeNil)
+
+			expectedRules := []*rules.FailureAssociationRule{
+				{
+					Project:         "chromium",
+					RuleDefinition:  `test = "testname-0"`,
+					BugID:           bugs.BugID{System: "monorail", ID: "chromium/100"},
+					SourceCluster:   suggestedClusters[0].ClusterID,
+					IsActive:        true,
+					IsManagingBug:   true,
+					CreationUser:    rules.WeetbixSystem,
+					LastUpdatedUser: rules.WeetbixSystem,
+				},
+				{
+					Project:         "chromium",
+					RuleDefinition:  `reason LIKE "want foo, got bar"`,
+					BugID:           bugs.BugID{System: "monorail", ID: "chromium/101"},
+					SourceCluster:   suggestedClusters[1].ClusterID,
+					IsActive:        true,
+					IsManagingBug:   true,
+					CreationUser:    rules.WeetbixSystem,
+					LastUpdatedUser: rules.WeetbixSystem,
+				},
+				{
+					Project:         "chromium",
+					RuleDefinition:  `reason LIKE "want foofoo, got bar"`,
+					BugID:           bugs.BugID{System: "monorail", ID: "chromium/102"},
+					SourceCluster:   suggestedClusters[2].ClusterID,
+					IsActive:        true,
+					IsManagingBug:   true,
+					CreationUser:    rules.WeetbixSystem,
+					LastUpdatedUser: rules.WeetbixSystem,
+				},
+			}
+
+			expectRules := func(expectedRules []*rules.FailureAssociationRule) {
+				// Check final set of rules is as expected.
+				rs, err := rules.ReadAll(span.Single(ctx), "chromium")
+				So(err, ShouldBeNil)
+				for _, r := range rs {
+					So(r.RuleID, ShouldNotBeEmpty)
+					So(r.CreationTime, ShouldNotBeZeroValue)
+					So(r.LastUpdated, ShouldNotBeZeroValue)
+					So(r.PredicateLastUpdated, ShouldNotBeZeroValue)
+					// Accept whatever values the implementation has set.
+					r.RuleID = ""
+					r.CreationTime = time.Time{}
+					r.LastUpdated = time.Time{}
+					r.PredicateLastUpdated = time.Time{}
+				}
+
+				sortedExpected := make([]*rules.FailureAssociationRule, len(expectedRules))
+				copy(sortedExpected, expectedRules)
+				sort.Slice(sortedExpected, func(i, j int) bool {
+					return sortedExpected[i].BugID.System < sortedExpected[j].BugID.System ||
+						(sortedExpected[i].BugID.System == sortedExpected[j].BugID.System &&
+							sortedExpected[i].BugID.ID < sortedExpected[j].BugID.ID)
+				})
+
+				So(rs, ShouldResemble, sortedExpected)
+				So(len(f.Issues), ShouldEqual, len(sortedExpected))
+			}
+			expectRules(expectedRules)
+
+			// Further updates do nothing.
+			originalIssues := monorail.CopyIssuesStore(f)
+			err = updateAnalysisAndBugsForProject(ctx, opts)
+			So(err, ShouldBeNil)
+			So(f, monorail.ShouldResembleIssuesStore, originalIssues)
+			expectRules(expectedRules)
+
+			rs, err := rules.ReadActive(span.Single(ctx), project)
+			So(err, ShouldBeNil)
+
+			bugClusters := []*analysis.Cluster{
+				makeBugCluster(rs[0].RuleID),
+				makeBugCluster(rs[1].RuleID),
+				makeBugCluster(rs[2].RuleID),
+			}
+
+			Convey("Re-clustering in progress", func() {
+				ac.clusters = append(suggestedClusters, bugClusters[1:]...)
+
+				Convey("Negligable cluster impact does not affect issue priority or status", func() {
+					issue := f.Issues[0].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/100")
+					originalPriority := monorail.ChromiumTestIssuePriority(issue)
+					originalStatus := issue.Status.Status
+					So(originalStatus, ShouldNotEqual, monorail.VerifiedStatus)
+
+					SetResidualImpact(
+						bugClusters[1], monorail.ChromiumClosureImpact())
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					So(len(f.Issues), ShouldEqual, 3)
+					issue = f.Issues[0].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/100")
+					So(monorail.ChromiumTestIssuePriority(issue), ShouldEqual, originalPriority)
+					So(issue.Status.Status, ShouldEqual, originalStatus)
+
+					expectRules(expectedRules)
+				})
+			})
+			Convey("Re-clustering complete", func() {
+				ac.clusters = append(suggestedClusters, bugClusters[1:]...)
+
+				// Copy impact from suggested clusters to new bug clusters.
+				bugClusters[0].Failures7d = suggestedClusters[0].Failures7d
+				bugClusters[1].Failures3d = suggestedClusters[1].Failures3d
+				bugClusters[1].Failures7d = suggestedClusters[1].Failures7d
+				bugClusters[2].Failures1d = suggestedClusters[2].Failures1d
+				bugClusters[2].Failures3d = suggestedClusters[2].Failures3d
+				bugClusters[2].Failures7d = suggestedClusters[2].Failures7d
+
+				// Clear residual impact on suggested clusters
+				suggestedClusters[0].Failures7d.Residual = 0
+				suggestedClusters[1].Failures3d.Residual = 0
+				suggestedClusters[1].Failures7d.Residual = 0
+				suggestedClusters[2].Failures1d.Residual = 0
+				suggestedClusters[2].Failures3d.Residual = 0
+				suggestedClusters[2].Failures7d.Residual = 0
+
+				// Mark reclustering complete.
+				err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{
+					runs.NewRun(0).
+						WithProject(project).
+						WithAlgorithmsVersion(algorithms.AlgorithmsVersion).
+						WithConfigVersion(projectCfg.LastUpdated.AsTime()).
+						WithRulesVersion(rs[2].PredicateLastUpdated).
+						WithCompletedProgress().Build(),
+				})
+				So(err, ShouldBeNil)
+
+				Convey("Cluster impact does not change if bug not managed by rule", func() {
+					// Set IsManagingBug to false on one rule.
+					rs[2].IsManagingBug = false
+					rules.SetRulesForTesting(ctx, rs)
+
+					issue := f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					originalPriority := monorail.ChromiumTestIssuePriority(issue)
+					originalStatus := issue.Status.Status
+					So(originalPriority, ShouldNotEqual, "0")
+
+					// Set P0 impact on the cluster.
+					SetResidualImpact(
+						bugClusters[2], monorail.ChromiumP0Impact())
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					// Check that the rule priority and status has not changed.
+					So(len(f.Issues), ShouldEqual, 3)
+					issue = f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(issue.Status.Status, ShouldEqual, originalStatus)
+					So(monorail.ChromiumTestIssuePriority(issue), ShouldEqual, originalPriority)
+				})
+				Convey("Increasing cluster impact increases issue priority", func() {
+					issue := f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(monorail.ChromiumTestIssuePriority(issue), ShouldNotEqual, "0")
+
+					SetResidualImpact(
+						bugClusters[2], monorail.ChromiumP0Impact())
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					So(len(f.Issues), ShouldEqual, 3)
+					issue = f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(monorail.ChromiumTestIssuePriority(issue), ShouldEqual, "0")
+
+					expectRules(expectedRules)
+				})
+				Convey("Decreasing cluster impact decreases issue priority", func() {
+					issue := f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(monorail.ChromiumTestIssuePriority(issue), ShouldNotEqual, "3")
+
+					SetResidualImpact(
+						bugClusters[2], monorail.ChromiumP3Impact())
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					So(len(f.Issues), ShouldEqual, 3)
+					issue = f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(issue.Status.Status, ShouldEqual, monorail.UntriagedStatus)
+					So(monorail.ChromiumTestIssuePriority(issue), ShouldEqual, "3")
+
+					expectRules(expectedRules)
+				})
+				Convey("Deleting cluster closes issue", func() {
+					issue := f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(issue.Status.Status, ShouldEqual, monorail.UntriagedStatus)
+
+					// Drop the bug cluster at index 2.
+					bugClusters = bugClusters[:2]
+					ac.clusters = append(suggestedClusters, bugClusters...)
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					So(len(f.Issues), ShouldEqual, 3)
+					issue = f.Issues[2].Issue
+					So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+					So(issue.Status.Status, ShouldEqual, monorail.VerifiedStatus)
+
+					expectRules(expectedRules)
+
+					Convey("Rule automatically archived after 30 days", func() {
+						tc.Add(time.Hour * 24 * 30)
+
+						// Act
+						err = updateAnalysisAndBugsForProject(ctx, opts)
+						So(err, ShouldBeNil)
+
+						// Verify
+						expectedRules[2].IsActive = false
+						expectRules(expectedRules)
+
+						So(len(f.Issues), ShouldEqual, 3)
+						issue = f.Issues[2].Issue
+						So(issue.Name, ShouldEqual, "projects/chromium/issues/102")
+						So(issue.Status.Status, ShouldEqual, monorail.VerifiedStatus)
+					})
+				})
+				Convey("Bug marked as duplicate of bug with rule", func() {
+					// Setup
+					issueOne := f.Issues[1].Issue
+					So(issueOne.Name, ShouldEqual, "projects/chromium/issues/101")
+
+					issueTwo := f.Issues[2].Issue
+					So(issueTwo.Name, ShouldEqual, "projects/chromium/issues/102")
+
+					issueOne.Status.Status = monorail.DuplicateStatus
+					issueOne.MergedIntoIssueRef = &api_proto.IssueRef{
+						Issue: issueTwo.Name,
+					}
+
+					// Act
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					// Verify
+					expectedRules[1].IsActive = false
+					expectedRules[2].RuleDefinition = "reason LIKE \"want foofoo, got bar\" OR\nreason LIKE \"want foo, got bar\""
+					expectRules(expectedRules)
+				})
+				Convey("Bug marked as duplicate of bug without a rule in this project", func() {
+					// Setup
+					issueOne := f.Issues[1].Issue
+					So(issueOne.Name, ShouldEqual, "projects/chromium/issues/101")
+
+					issueOne.Status.Status = monorail.DuplicateStatus
+					issueOne.MergedIntoIssueRef = &api_proto.IssueRef{
+						ExtIdentifier: "b/1234",
+					}
+
+					Convey("Bug managed by a rule in another project", func() {
+						extraRule := &rules.FailureAssociationRule{
+							Project:        "otherproject",
+							RuleDefinition: `reason LIKE "blah"`,
+							RuleID:         "1234567890abcdef1234567890abcdef",
+							BugID:          bugs.BugID{System: "buganizer", ID: "1234"},
+							IsActive:       true,
+							IsManagingBug:  true,
+						}
+						_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+							return rules.Create(ctx, extraRule, "user@chromium.org")
+						})
+						So(err, ShouldBeNil)
+
+						// Act
+						err = updateAnalysisAndBugsForProject(ctx, opts)
+						So(err, ShouldBeNil)
+
+						// Verify
+						expectedRules[1].BugID = bugs.BugID{System: "buganizer", ID: "1234"}
+						expectedRules[1].IsManagingBug = false // Let the other rule continue to manage the bug.
+						expectRules(expectedRules)
+					})
+					Convey("Bug not managed by a rule in another project", func() {
+						// Act
+						err = updateAnalysisAndBugsForProject(ctx, opts)
+						So(err, ShouldBeNil)
+
+						// Verify
+						expectedRules[1].BugID = bugs.BugID{System: "buganizer", ID: "1234"}
+						expectedRules[1].IsManagingBug = true
+						expectRules(expectedRules)
+					})
+				})
+				Convey("Bug marked as duplicate in a cycle", func() {
+					// Setup
+					// Note that this is a simple cycle with only two bugs.
+					// The implementation allows for larger cycles, however.
+					issueOne := f.Issues[1].Issue
+					So(issueOne.Name, ShouldEqual, "projects/chromium/issues/101")
+
+					issueTwo := f.Issues[2].Issue
+					So(issueTwo.Name, ShouldEqual, "projects/chromium/issues/102")
+
+					issueOne.Status.Status = monorail.DuplicateStatus
+					issueOne.MergedIntoIssueRef = &api_proto.IssueRef{
+						Issue: issueTwo.Name,
+					}
+
+					issueTwo.Status.Status = monorail.DuplicateStatus
+					issueTwo.MergedIntoIssueRef = &api_proto.IssueRef{
+						Issue: issueOne.Name,
+					}
+
+					// Act
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					// Verify
+					// Issue one kicked out of duplicate status.
+					So(issueOne.Status.Status, ShouldNotEqual, monorail.DuplicateStatus)
+					So(f.Issues[1].Comments, ShouldHaveLength, 3)
+					So(f.Issues[1].Comments[2].Content, ShouldContainSubstring, "a cycle was detected in the bug merged-into graph")
+
+					// As the cycle is now broken, issue two is merged into
+					// issue one.
+					expectedRules[1].RuleDefinition = "reason LIKE \"want foo, got bar\" OR\nreason LIKE \"want foofoo, got bar\""
+					expectedRules[2].IsActive = false
+					expectRules(expectedRules)
+				})
+				Convey("Bug marked as archived should archive rule", func() {
+					issueOne := f.Issues[1].Issue
+					So(issueOne.Name, ShouldEqual, "projects/chromium/issues/101")
+					issueOne.Status = &api_proto.Issue_StatusValue{Status: "Archived"}
+
+					// Act
+					err = updateAnalysisAndBugsForProject(ctx, opts)
+					So(err, ShouldBeNil)
+
+					// Assert
+					expectedRules[1].IsActive = false
+					expectRules(expectedRules)
+				})
+			})
+		})
+	})
+}
+
+func makeTestNameCluster(config *compiledcfg.ProjectConfig, uniqifier int) *analysis.Cluster {
+	testID := fmt.Sprintf("testname-%v", uniqifier)
+	return &analysis.Cluster{
+		ClusterID:  testIDClusterID(config, testID),
+		Failures1d: analysis.Counts{Residual: 9},
+		Failures3d: analysis.Counts{Residual: 29},
+		Failures7d: analysis.Counts{Residual: 69},
+		TopTestIDs: []analysis.TopCount{{Value: testID, Count: 1}},
+	}
+}
+
+func makeReasonCluster(config *compiledcfg.ProjectConfig, uniqifier int) *analysis.Cluster {
+	// Because the failure reason clustering algorithm removes numbers
+	// when clustering failure reasons, it is better not to use the
+	// uniqifier directly in the reason, to avoid cluster ID collisions.
+	var foo strings.Builder
+	for i := 0; i < uniqifier; i++ {
+		foo.WriteString("foo")
+	}
+	reason := fmt.Sprintf("want %s, got bar", foo.String())
+
+	return &analysis.Cluster{
+		ClusterID:  reasonClusterID(config, reason),
+		Failures1d: analysis.Counts{Residual: 9},
+		Failures3d: analysis.Counts{Residual: 29},
+		Failures7d: analysis.Counts{Residual: 69},
+		TopTestIDs: []analysis.TopCount{
+			{Value: fmt.Sprintf("testname-a-%v", uniqifier), Count: 1},
+			{Value: fmt.Sprintf("testname-b-%v", uniqifier), Count: 1},
+		},
+		ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: reason},
+	}
+}
+
+func makeBugCluster(ruleID string) *analysis.Cluster {
+	return &analysis.Cluster{
+		ClusterID:  bugClusterID(ruleID),
+		Failures1d: analysis.Counts{Residual: 9},
+		Failures3d: analysis.Counts{Residual: 29},
+		Failures7d: analysis.Counts{Residual: 69},
+		TopTestIDs: []analysis.TopCount{{Value: "testname-0", Count: 1}},
+	}
+}
+
+func testIDClusterID(config *compiledcfg.ProjectConfig, testID string) clustering.ClusterID {
+	testAlg, err := algorithms.SuggestingAlgorithm(testname.AlgorithmName)
+	So(err, ShouldBeNil)
+
+	return clustering.ClusterID{
+		Algorithm: testname.AlgorithmName,
+		ID: hex.EncodeToString(testAlg.Cluster(config, &clustering.Failure{
+			TestID: testID,
+		})),
+	}
+}
+
+func reasonClusterID(config *compiledcfg.ProjectConfig, reason string) clustering.ClusterID {
+	reasonAlg, err := algorithms.SuggestingAlgorithm(failurereason.AlgorithmName)
+	So(err, ShouldBeNil)
+
+	return clustering.ClusterID{
+		Algorithm: failurereason.AlgorithmName,
+		ID: hex.EncodeToString(reasonAlg.Cluster(config, &clustering.Failure{
+			Reason: &pb.FailureReason{PrimaryErrorMessage: reason},
+		})),
+	}
+}
+
+func bugClusterID(ruleID string) clustering.ClusterID {
+	return clustering.ClusterID{
+		Algorithm: rulesalgorithm.AlgorithmName,
+		ID:        ruleID,
+	}
+}
+
+type fakeAnalysisClient struct {
+	analysisBuilt bool
+	clusters      []*analysis.Cluster
+}
+
+func (f *fakeAnalysisClient) RebuildAnalysis(ctx context.Context, project string) error {
+	f.analysisBuilt = true
+	return nil
+}
+
+func (f *fakeAnalysisClient) PurgeStaleRows(ctx context.Context, luciProject string) error {
+	return nil
+}
+
+func (f *fakeAnalysisClient) ReadImpactfulClusters(ctx context.Context, opts analysis.ImpactfulClusterReadOptions) ([]*analysis.Cluster, error) {
+	if !f.analysisBuilt {
+		return nil, errors.New("cluster_summaries does not exist")
+	}
+	var results []*analysis.Cluster
+	for _, c := range f.clusters {
+		include := opts.AlwaysIncludeBugClusters && c.ClusterID.IsBugCluster()
+		if opts.Thresholds.CriticalFailuresExonerated != nil {
+			include = include ||
+				meetsThreshold(c.CriticalFailuresExonerated1d.Residual, opts.Thresholds.CriticalFailuresExonerated.OneDay) ||
+				meetsThreshold(c.CriticalFailuresExonerated3d.Residual, opts.Thresholds.CriticalFailuresExonerated.ThreeDay) ||
+				meetsThreshold(c.CriticalFailuresExonerated7d.Residual, opts.Thresholds.CriticalFailuresExonerated.SevenDay)
+		}
+		if opts.Thresholds.TestResultsFailed != nil {
+			include = include ||
+				meetsThreshold(c.Failures1d.Residual, opts.Thresholds.TestResultsFailed.OneDay) ||
+				meetsThreshold(c.Failures3d.Residual, opts.Thresholds.TestResultsFailed.ThreeDay) ||
+				meetsThreshold(c.Failures7d.Residual, opts.Thresholds.TestResultsFailed.SevenDay)
+		}
+		if opts.Thresholds.TestRunsFailed != nil {
+			include = include ||
+				meetsThreshold(c.TestRunFails1d.Residual, opts.Thresholds.TestRunsFailed.OneDay) ||
+				meetsThreshold(c.TestRunFails3d.Residual, opts.Thresholds.TestRunsFailed.ThreeDay) ||
+				meetsThreshold(c.TestRunFails7d.Residual, opts.Thresholds.TestRunsFailed.SevenDay)
+		}
+		if opts.Thresholds.PresubmitRunsFailed != nil {
+			include = include ||
+				meetsThreshold(c.PresubmitRejects1d.Residual, opts.Thresholds.PresubmitRunsFailed.OneDay) ||
+				meetsThreshold(c.PresubmitRejects3d.Residual, opts.Thresholds.PresubmitRunsFailed.ThreeDay) ||
+				meetsThreshold(c.PresubmitRejects7d.Residual, opts.Thresholds.PresubmitRunsFailed.SevenDay)
+		}
+		if include {
+			results = append(results, c)
+		}
+	}
+	return results, nil
+}
+
+func meetsThreshold(value int64, threshold *int64) bool {
+	// threshold == nil is treated as an unsatisfiable threshold.
+	return threshold != nil && value >= *threshold
+}
diff --git a/analysis/internal/buildbucket/buildbucket.go b/analysis/internal/buildbucket/buildbucket.go
new file mode 100644
index 0000000..aa79df9
--- /dev/null
+++ b/analysis/internal/buildbucket/buildbucket.go
@@ -0,0 +1,68 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package buildbucket contains logic of interacting with Buildbucket.
+package buildbucket
+
+import (
+	"context"
+	"net/http"
+
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/grpc/prpc"
+	"go.chromium.org/luci/server/auth"
+)
+
+// mockedBBClientKey is the context key indicates using mocked buildbucket client in tests.
+var mockedBBClientKey = "used in tests only for setting the mock buildbucket client"
+
+func newBuildsClient(ctx context.Context, host string) (bbpb.BuildsClient, error) {
+	if mockClient, ok := ctx.Value(&mockedBBClientKey).(*bbpb.MockBuildsClient); ok {
+		return mockClient, nil
+	}
+
+	t, err := auth.GetRPCTransport(ctx, auth.AsSelf)
+	if err != nil {
+		return nil, err
+	}
+	return bbpb.NewBuildsPRPCClient(
+		&prpc.Client{
+			C:       &http.Client{Transport: t},
+			Host:    host,
+			Options: prpc.DefaultOptions(),
+		}), nil
+}
+
+// Client is the client to communicate with Buildbucket.
+// It wraps a bbpb.BuildsClient.
+type Client struct {
+	client bbpb.BuildsClient
+}
+
+// NewClient creates a client to communicate with Buildbucket.
+func NewClient(ctx context.Context, host string) (*Client, error) {
+	client, err := newBuildsClient(ctx, host)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Client{
+		client: client,
+	}, nil
+}
+
+// GetBuild returns bbpb.Build for the requested build.
+func (c *Client) GetBuild(ctx context.Context, req *bbpb.GetBuildRequest) (*bbpb.Build, error) {
+	return c.client.GetBuild(ctx, req)
+}
diff --git a/analysis/internal/buildbucket/buildbucket_test.go b/analysis/internal/buildbucket/buildbucket_test.go
new file mode 100644
index 0000000..4c3e911
--- /dev/null
+++ b/analysis/internal/buildbucket/buildbucket_test.go
@@ -0,0 +1,76 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package buildbucket
+
+import (
+	"context"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+
+	"google.golang.org/genproto/protobuf/field_mask"
+	"google.golang.org/protobuf/proto"
+
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestGetBuild(t *testing.T) {
+	t.Parallel()
+
+	Convey("Get build", t, func() {
+
+		ctl := gomock.NewController(t)
+		defer ctl.Finish()
+		mc := NewMockedClient(context.Background(), ctl)
+
+		bID := int64(87654321)
+		inv := "invocations/build-87654321"
+
+		req := &bbpb.GetBuildRequest{
+			Id: bID,
+			Mask: &bbpb.BuildMask{
+				Fields: &field_mask.FieldMask{
+					Paths: []string{"builder", "infra.resultdb", "status"},
+				},
+			},
+		}
+
+		res := &bbpb.Build{
+			Builder: &bbpb.BuilderID{
+				Project: "chromium",
+				Bucket:  "ci",
+				Builder: "builder",
+			},
+			Infra: &bbpb.BuildInfra{
+				Resultdb: &bbpb.BuildInfra_ResultDB{
+					Hostname:   "results.api.cr.dev",
+					Invocation: inv,
+				},
+			},
+			Status: bbpb.Status_FAILURE,
+		}
+		reqCopy := proto.Clone(req).(*bbpb.GetBuildRequest)
+		mc.GetBuild(reqCopy, res)
+
+		bc, err := NewClient(mc.Ctx, "bbhost")
+		So(err, ShouldBeNil)
+		b, err := bc.GetBuild(mc.Ctx, req)
+		So(err, ShouldBeNil)
+		So(b, ShouldResembleProto, res)
+	})
+}
diff --git a/analysis/internal/buildbucket/testutil.go b/analysis/internal/buildbucket/testutil.go
new file mode 100644
index 0000000..7c42641
--- /dev/null
+++ b/analysis/internal/buildbucket/testutil.go
@@ -0,0 +1,45 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package buildbucket
+
+import (
+	"context"
+
+	"github.com/golang/mock/gomock"
+
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/common/proto"
+)
+
+// MockedClient is a mocked Buildbucket client for testing.
+// It wraps a bbpb.MockBuildsClient and a context with the mocked client.
+type MockedClient struct {
+	Client *bbpb.MockBuildsClient
+	Ctx    context.Context
+}
+
+// NewMockedClient creates a MockedClient for testing.
+func NewMockedClient(ctx context.Context, ctl *gomock.Controller) *MockedClient {
+	mockClient := bbpb.NewMockBuildsClient(ctl)
+	return &MockedClient{
+		Client: mockClient,
+		Ctx:    context.WithValue(ctx, &mockedBBClientKey, mockClient),
+	}
+}
+
+// GetBuild Mocks the GetBuild RPC.
+func (mc *MockedClient) GetBuild(req *bbpb.GetBuildRequest, res *bbpb.Build) {
+	mc.Client.EXPECT().GetBuild(gomock.Any(), proto.MatcherEqual(req), gomock.Any()).Return(res, nil)
+}
diff --git a/analysis/internal/clustering/algorithms/cluster.go b/analysis/internal/clustering/algorithms/cluster.go
new file mode 100644
index 0000000..8e71ba5
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/cluster.go
@@ -0,0 +1,268 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package algorithms
+
+import (
+	"encoding/hex"
+	"errors"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+)
+
+// Algorithm represents the interface that each clustering algorithm
+// generating suggested clusters must implement.
+type Algorithm interface {
+	// Name returns the identifier of the clustering algorithm.
+	Name() string
+	// Cluster clusters the given test failure and returns its cluster ID (if
+	// it can be clustered) or nil otherwise. THe returned cluster ID must be
+	// at most 16 bytes.
+	Cluster(config *compiledcfg.ProjectConfig, failure *clustering.Failure) []byte
+	// FailureAssociationRule returns a failure association rule that
+	// captures the definition of the cluster containing the given example.
+	FailureAssociationRule(config *compiledcfg.ProjectConfig, example *clustering.Failure) string
+	// ClusterKey returns the unhashed clustering key which is common
+	// across all test results in a cluster. This will be displayed
+	// on the cluster page or cluster listing.
+	ClusterKey(config *compiledcfg.ProjectConfig, example *clustering.Failure) string
+	// ClusterDescription returns a description of the cluster, for use when
+	// filing bugs, with the help of the given example failure.
+	ClusterDescription(config *compiledcfg.ProjectConfig, summary *clustering.ClusterSummary) (*clustering.ClusterDescription, error)
+}
+
+// AlgorithmsVersion is the version of the set of algorithms used.
+// Changing the set of algorithms below (including add, update or
+// deletion of an algorithm) should result in this version being
+// incremented.
+//
+// In case of algorithm deletion, make sure to update this constant
+// appropriately to ensure the AlgorithmsVersion still increases
+// (I.E. DO NOT simply delete "+ <myalgorithm>.AlgorithmVersion"
+// when deleting an algorithm without rolling its value (plus one)
+// into the constant.)
+const AlgorithmsVersion = 1 + failurereason.AlgorithmVersion +
+	testname.AlgorithmVersion + rulesalgorithm.AlgorithmVersion
+
+// suggestingAlgorithms is the set of clustering algorithms used by
+// Weetbix to generate suggested clusters.
+// When an algorithm is added or removed from the set,
+// or when an algorithm is updated, ensure the AlgorithmsVersion
+// above increments.
+var suggestingAlgorithms = []Algorithm{
+	&failurereason.Algorithm{},
+	&testname.Algorithm{},
+}
+
+// rulesAlgorithm is the rules-based clustering algorithm used by
+// Weetbix. When this algorithm is changed, ensure the AlgorithmsVersion
+// above increments.
+var rulesAlgorithm = rulesalgorithm.Algorithm{}
+
+// The set of all algorithms known by Weetbix.
+var algorithmNames map[string]struct{}
+
+// The set of all suggested algorithms known by Weetbix.
+var suggestedAlgorithmNames map[string]struct{}
+
+func init() {
+	algorithmNames = make(map[string]struct{})
+	suggestedAlgorithmNames = make(map[string]struct{})
+	algorithmNames[rulesalgorithm.AlgorithmName] = struct{}{}
+	for _, a := range suggestingAlgorithms {
+		algorithmNames[a.Name()] = struct{}{}
+		suggestedAlgorithmNames[a.Name()] = struct{}{}
+	}
+}
+
+// Cluster performs (incremental re-)clustering of the given test
+// failures using all registered clustering algorithms and the
+// specified set of failure association rules and config.
+//
+// If the test results have not been previously clustered, pass
+// an existing ClusterResults of NewEmptyClusterResults(...)
+// to cluster test results from scratch.
+//
+// If the test results have been previously clustered, pass the
+// ClusterResults returned by the last call to Cluster.
+//
+// Cluster(...) will always return a set of ClusterResults which
+// are as- or more-recent than the existing ClusterResults.
+// This is defined as the following postcondition:
+//  returned.AlgorithmsVersion > existing.AlgorithmsVersion ||
+//  (returned.AlgorithmsVersion == existing.AlgorithmsVersion &&
+//    returned.ConfigVersion >= existing.ConfigVersion &&
+//    returned.RulesVersion >= existing.RulesVersion)
+func Cluster(config *compiledcfg.ProjectConfig, ruleset *cache.Ruleset, existing clustering.ClusterResults, failures []*clustering.Failure) clustering.ClusterResults {
+	if existing.AlgorithmsVersion > AlgorithmsVersion {
+		// We are running out-of-date clustering algorithms. Do not
+		// try to improve on the existing clustering. This can
+		// happen if we are rolling out a new version of Weetbix.
+		return existing
+	}
+
+	newSuggestedAlgorithms := false
+	for _, alg := range suggestingAlgorithms {
+		if _, ok := existing.Algorithms[alg.Name()]; !ok {
+			newSuggestedAlgorithms = true
+		}
+	}
+	// We should recycle the previous suggested clusters for performance if:
+	// (1) the algorithms to be run are the same (or a subset)
+	//     of what was previously run, and
+	// (2) the config available to us is not later than
+	//     what was available when clustering occurred.
+	//
+	// Implied is that we may only update to suggested clusters based
+	// on an earlier version of config if there are new algorithms.
+	reuseSuggestedAlgorithmResults := !newSuggestedAlgorithms &&
+		!config.LastUpdated.After(existing.ConfigVersion)
+
+	// For rule-based clustering.
+	_, reuseRuleAlgorithmResults := existing.Algorithms[rulesalgorithm.AlgorithmName]
+	existingRulesVersion := existing.RulesVersion
+	if !reuseRuleAlgorithmResults {
+		// Although we may have previously run rule-based clustering, we did
+		// not run the current version of that algorithm. Invalidate all
+		// previous analysis; match against all rules again.
+		existingRulesVersion = rules.StartingEpoch
+	}
+
+	result := make([][]clustering.ClusterID, len(failures))
+	for i, f := range failures {
+		newIDs := make([]clustering.ClusterID, 0, len(suggestingAlgorithms)+2)
+		ruleIDs := make(map[string]struct{})
+
+		existingIDs := existing.Clusters[i]
+		for _, id := range existingIDs {
+			if reuseSuggestedAlgorithmResults {
+				if _, ok := suggestedAlgorithmNames[id.Algorithm]; ok {
+					// The algorithm was run previously and its results are still valid.
+					// Retain its results.
+					newIDs = append(newIDs, id)
+				}
+			}
+			if reuseRuleAlgorithmResults && id.Algorithm == rulesalgorithm.AlgorithmName {
+				// The rules algorithm was previously run. Record the past results,
+				// but separately. Some previously matched rules may have been
+				// updated or made inactive since, so we need to treat these
+				// separately (and pass them to the rules algorithm to filter
+				// through).
+				ruleIDs[id.ID] = struct{}{}
+			}
+		}
+
+		if !reuseSuggestedAlgorithmResults {
+			// Run the suggested clustering algorithms.
+			for _, a := range suggestingAlgorithms {
+				id := a.Cluster(config, f)
+				if id == nil {
+					continue
+				}
+				newIDs = append(newIDs, clustering.ClusterID{
+					Algorithm: a.Name(),
+					ID:        hex.EncodeToString(id),
+				})
+			}
+		}
+
+		if ruleset.Version.Predicates.After(existingRulesVersion) {
+			// Match against the (incremental) set of rules.
+			rulesAlgorithm.Cluster(ruleset, existingRulesVersion, ruleIDs, f)
+		}
+		// Otherwise test results were already clustered with an equal or later
+		// version of rules. This can happen if our cached ruleset is out of date.
+		// Re-use the existing analysis in this case; don't try to improve on it.
+
+		for rID := range ruleIDs {
+			id := clustering.ClusterID{
+				Algorithm: rulesalgorithm.AlgorithmName,
+				ID:        rID,
+			}
+			newIDs = append(newIDs, id)
+		}
+
+		// Keep the output deterministic by sorting the clusters in the
+		// output.
+		clustering.SortClusters(newIDs)
+		result[i] = newIDs
+	}
+
+	// Base re-clustering on rule predicate changes,
+	// as only the rule predicate matters for clustering.
+	newRulesVersion := ruleset.Version.Predicates
+	if existingRulesVersion.After(newRulesVersion) {
+		// If the existing rule-matching is more current than our current
+		// ruleset allows, we will have kept its results, and should keep
+		// its RulesVersion.
+		// This can happen sometimes if our cached ruleset is out of date.
+		// This is normal.
+		newRulesVersion = existingRulesVersion
+	}
+	newConfigVersion := existing.ConfigVersion
+	if !reuseSuggestedAlgorithmResults {
+		// If the we recomputed the suggested clusters, record the version
+		// of config we used.
+		newConfigVersion = config.LastUpdated
+	}
+
+	return clustering.ClusterResults{
+		AlgorithmsVersion: AlgorithmsVersion,
+		ConfigVersion:     newConfigVersion,
+		RulesVersion:      newRulesVersion,
+		Algorithms:        algorithmNames,
+		Clusters:          result,
+	}
+}
+
+// ErrAlgorithmNotExist is returned if an algorithm with the given
+// name does not exist. This may indicate the algorithm
+// is newer or older than the current version.
+var ErrAlgorithmNotExist = errors.New("algorithm does not exist")
+
+// SuggestingAlgorithm returns the algorithm for generating
+// suggested clusters with the given name. If the algorithm does
+// not exist, ErrAlgorithmNotExist is returned.
+func SuggestingAlgorithm(algorithm string) (Algorithm, error) {
+	for _, a := range suggestingAlgorithms {
+		if a.Name() == algorithm {
+			return a, nil
+		}
+	}
+	// We may be running old code, or the caller may be asking
+	// for an old (version of an) algorithm.
+	return nil, ErrAlgorithmNotExist
+}
+
+// NewEmptyClusterResults returns a new ClusterResults for a list of
+// test results of length count. The ClusterResults will indicate the
+// test results have not been clustered.
+func NewEmptyClusterResults(count int) clustering.ClusterResults {
+	return clustering.ClusterResults{
+		// Algorithms version 0 is the empty set of clustering algorithms.
+		AlgorithmsVersion: 0,
+		ConfigVersion:     config.StartingEpoch,
+		// The RulesVersion StartingEpoch refers to the empty set of rules.
+		RulesVersion: rules.StartingEpoch,
+		Algorithms:   make(map[string]struct{}),
+		Clusters:     make([][]clustering.ClusterID, count),
+	}
+}
diff --git a/analysis/internal/clustering/algorithms/cluster_test.go b/analysis/internal/clustering/algorithms/cluster_test.go
new file mode 100644
index 0000000..8a69e64
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/cluster_test.go
@@ -0,0 +1,468 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package algorithms
+
+import (
+	"encoding/hex"
+	"fmt"
+	"strings"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestCluster(t *testing.T) {
+	Convey(`Cluster`, t, func() {
+		Convey(`From scratch`, func() {
+			s := fromScratchScenario(1)
+
+			results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+			So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+			So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+			So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+			So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+		})
+		Convey(`Incrementally`, func() {
+			Convey(`From already up-to-date clustering`, func() {
+				s := upToDateScenario(1)
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+
+			Convey(`From older suggested clustering algorithm`, func() {
+				s := fromOlderSuggestedClusteringScenario()
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+			Convey(`Incrementally from older rule-based clustering`, func() {
+				s := fromOlderRuleAlgorithmScenario()
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+			Convey(`Incrementally from later clustering algorithms`, func() {
+				s := fromLaterAlgorithmsScenario()
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+			Convey(`Incrementally from older rules version`, func() {
+				s := fromOlderRulesVersionScenario(1)
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+			Convey(`Incrementally from newer rules version`, func() {
+				s := fromNewerRulesVersionScenario()
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+			Convey(`Incrementally from older config version`, func() {
+				s := fromOlderConfigVersionScenario()
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+			Convey(`Incrementally from newer config version`, func() {
+				s := fromNewerConfigVersionScenario()
+
+				results := Cluster(s.config, s.ruleset, s.existing, s.failures)
+
+				So(results.AlgorithmsVersion, ShouldEqual, s.expected.AlgorithmsVersion)
+				So(results.RulesVersion, ShouldEqual, s.expected.RulesVersion)
+				So(results.Algorithms, ShouldResemble, s.expected.Algorithms)
+				So(diffClusters(results.Clusters, s.expected.Clusters), ShouldBeBlank)
+			})
+		})
+	})
+}
+
+func BenchmarkClusteringFromScratch(b *testing.B) {
+	b.StopTimer()
+	s := fromScratchScenario(1000)
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		_ = Cluster(s.config, s.ruleset, s.existing, s.failures)
+	}
+}
+
+func BenchmarkClusteringFromOlderRules(b *testing.B) {
+	b.StopTimer()
+	s := fromOlderRulesVersionScenario(1000)
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		_ = Cluster(s.config, s.ruleset, s.existing, s.failures)
+	}
+}
+
+func BenchmarkClusteringUpToDate(b *testing.B) {
+	b.StopTimer()
+	s := upToDateScenario(1000)
+	b.StartTimer()
+	for i := 0; i < b.N; i++ {
+		_ = Cluster(s.config, s.ruleset, s.existing, s.failures)
+	}
+}
+
+type scenario struct {
+	failures []*clustering.Failure
+	rules    []*cache.CachedRule
+	ruleset  *cache.Ruleset
+	config   *compiledcfg.ProjectConfig
+	existing clustering.ClusterResults
+	expected clustering.ClusterResults
+}
+
+func upToDateScenario(size int) *scenario {
+	rulesVersion := rules.Version{
+		Predicates: time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC),
+	}
+
+	rule1, err := cache.NewCachedRule(
+		rules.NewRule(100).
+			WithRuleDefinition(`test = "ninja://test_name/2"`).
+			WithPredicateLastUpdated(rulesVersion.Predicates.Add(-1 * time.Hour)).
+			Build())
+	if err != nil {
+		panic(err)
+	}
+
+	rule2, err := cache.NewCachedRule(
+		rules.NewRule(101).
+			WithRuleDefinition(`reason LIKE "failed to connect to %.%.%.%"`).
+			WithPredicateLastUpdated(rulesVersion.Predicates).Build())
+	if err != nil {
+		panic(err)
+	}
+
+	lastUpdated := time.Now()
+	rules := []*cache.CachedRule{rule1, rule2}
+	ruleset := cache.NewRuleset("myproject", rules, rulesVersion, lastUpdated)
+
+	cfgpb := &configpb.ProjectConfig{
+		Clustering:  TestClusteringConfig(),
+		LastUpdated: timestamppb.New(time.Date(2020, time.February, 1, 1, 0, 0, 0, time.UTC)),
+	}
+	cfg, err := compiledcfg.NewConfig(cfgpb)
+	if err != nil {
+		// Should not occur, test data should be valid.
+		panic(err)
+	}
+
+	failures := []*clustering.Failure{
+		{
+			TestID: "ninja://test_name/1",
+		},
+	}
+	for i := 0; i < size; i++ {
+		failures = append(failures,
+			&clustering.Failure{
+				TestID: "ninja://test_name/2",
+				Reason: &pb.FailureReason{
+					PrimaryErrorMessage: "failed to connect to 192.168.0.1",
+				},
+			})
+	}
+
+	// This is an up-to-date clustering of the test results.
+	existing := clustering.ClusterResults{
+		AlgorithmsVersion: AlgorithmsVersion,
+		ConfigVersion:     cfg.LastUpdated,
+		RulesVersion:      rulesVersion.Predicates,
+		Algorithms: map[string]struct{}{
+			failurereason.AlgorithmName:  {},
+			rulesalgorithm.AlgorithmName: {},
+			testname.AlgorithmName:       {},
+		},
+		Clusters: [][]clustering.ClusterID{
+			{
+				testNameClusterID(cfg, failures[0]),
+			},
+		},
+	}
+	for i := 0; i < size; i++ {
+		clusters := []clustering.ClusterID{
+			failureReasonClusterID(cfg, failures[1]),
+			testNameClusterID(cfg, failures[1]),
+			ruleClusterID(rule1.Rule.RuleID),
+			ruleClusterID(rule2.Rule.RuleID),
+		}
+		clustering.SortClusters(clusters)
+		existing.Clusters = append(existing.Clusters, clusters)
+	}
+
+	// Same as existing, a deep copy as the other methods
+	// may modify this scenario and we don't want to run into
+	// unexpected aliasing issues.
+	expected := clustering.ClusterResults{
+		AlgorithmsVersion: AlgorithmsVersion,
+		ConfigVersion:     cfg.LastUpdated,
+		RulesVersion:      rulesVersion.Predicates,
+		Algorithms: map[string]struct{}{
+			failurereason.AlgorithmName:  {},
+			rulesalgorithm.AlgorithmName: {},
+			testname.AlgorithmName:       {},
+		},
+		Clusters: [][]clustering.ClusterID{
+			{
+				testNameClusterID(cfg, failures[0]),
+			},
+		},
+	}
+	for i := 0; i < size; i++ {
+		clusters := []clustering.ClusterID{
+			failureReasonClusterID(cfg, failures[1]),
+			testNameClusterID(cfg, failures[1]),
+			ruleClusterID(rule1.Rule.RuleID),
+			ruleClusterID(rule2.Rule.RuleID),
+		}
+		clustering.SortClusters(clusters)
+		expected.Clusters = append(expected.Clusters, clusters)
+	}
+
+	return &scenario{
+		failures: failures,
+		rules:    rules,
+		ruleset:  ruleset,
+		config:   cfg,
+		expected: expected,
+		existing: existing,
+	}
+}
+
+func fromOlderSuggestedClusteringScenario() *scenario {
+	s := upToDateScenario(1)
+	s.existing.AlgorithmsVersion--
+	delete(s.existing.Algorithms, failurereason.AlgorithmName)
+	s.existing.Algorithms["failurereason-v1"] = struct{}{}
+	s.existing.Clusters[1][0] = clustering.ClusterID{
+		Algorithm: "failurereason-v1",
+		ID:        "old-failure-reason-cluster-id",
+	}
+	clustering.SortClusters(s.existing.Clusters[1])
+	return s
+}
+
+func fromOlderRuleAlgorithmScenario() *scenario {
+	s := upToDateScenario(1)
+	s.existing.AlgorithmsVersion--
+	delete(s.existing.Algorithms, rulesalgorithm.AlgorithmName)
+	s.existing.Algorithms["rules-v0"] = struct{}{}
+	s.existing.Clusters[1] = []clustering.ClusterID{
+		failureReasonClusterID(s.config, s.failures[1]),
+		testNameClusterID(s.config, s.failures[1]),
+		{Algorithm: "rules-v0", ID: s.rules[0].Rule.RuleID},
+		{Algorithm: "rules-v0", ID: "rule-no-longer-matched-with-v1"},
+	}
+	clustering.SortClusters(s.existing.Clusters[1])
+	return s
+}
+
+func fromLaterAlgorithmsScenario() *scenario {
+	s := upToDateScenario(1)
+	s.existing.AlgorithmsVersion = AlgorithmsVersion + 1
+	s.existing.Algorithms = map[string]struct{}{
+		"futurealgorithm-v1": {},
+	}
+	s.existing.Clusters = [][]clustering.ClusterID{
+		{
+			{Algorithm: "futurealgorithm-v1", ID: "aa"},
+		},
+		{
+			{Algorithm: "futurealgorithm-v1", ID: "bb"},
+		},
+	}
+	// As the algorithms version is later, the clustering
+	// should be left completely untouched.
+	s.expected = s.existing
+	return s
+}
+
+func fromOlderRulesVersionScenario(size int) *scenario {
+	s := upToDateScenario(size)
+	s.existing.RulesVersion = s.existing.RulesVersion.Add(-1 * time.Hour)
+	for i := 1; i <= size; i++ {
+		s.existing.Clusters[i] = []clustering.ClusterID{
+			failureReasonClusterID(s.config, s.failures[i]),
+			testNameClusterID(s.config, s.failures[i]),
+			ruleClusterID(s.rules[0].Rule.RuleID),
+			ruleClusterID("now-deleted-rule-id"),
+		}
+		clustering.SortClusters(s.existing.Clusters[i])
+	}
+	return s
+}
+
+func fromNewerRulesVersionScenario() *scenario {
+	s := upToDateScenario(1)
+	s.existing.RulesVersion = s.existing.RulesVersion.Add(1 * time.Hour)
+	s.existing.Clusters[1] = []clustering.ClusterID{
+		failureReasonClusterID(s.config, s.failures[1]),
+		testNameClusterID(s.config, s.failures[1]),
+		ruleClusterID(s.rules[0].Rule.RuleID),
+		ruleClusterID("later-added-rule-id"),
+	}
+	clustering.SortClusters(s.existing.Clusters[1])
+
+	s.expected.RulesVersion = s.expected.RulesVersion.Add(1 * time.Hour)
+	// Should keep existing rule clusters, as they are newer.
+	s.expected.Clusters = s.existing.Clusters
+	return s
+}
+
+func fromOlderConfigVersionScenario() *scenario {
+	s := upToDateScenario(1)
+	oldConfigVersion := s.existing.ConfigVersion.Add(-1 * time.Hour)
+	s.existing.ConfigVersion = oldConfigVersion
+
+	for _, cs := range s.existing.Clusters {
+		for j := range cs {
+			if cs[j].Algorithm == testname.AlgorithmName {
+				cs[j].ID = hex.EncodeToString([]byte("old-test-cluster"))
+			}
+		}
+		clustering.SortClusters(cs)
+	}
+	return s
+}
+
+func fromNewerConfigVersionScenario() *scenario {
+	s := upToDateScenario(1)
+	newConfigVersion := s.existing.ConfigVersion.Add(1 * time.Hour)
+	s.existing.ConfigVersion = newConfigVersion
+
+	for _, cs := range s.existing.Clusters {
+		for j := range cs {
+			if cs[j].Algorithm == testname.AlgorithmName {
+				cs[j].ID = hex.EncodeToString([]byte("new-test-cluster"))
+			}
+		}
+		clustering.SortClusters(cs)
+	}
+
+	s.expected.ConfigVersion = newConfigVersion
+	// Should keep existing clusters, as they are newer.
+	s.expected.Clusters = s.existing.Clusters
+	return s
+}
+
+func fromScratchScenario(size int) *scenario {
+	s := upToDateScenario(size)
+	s.existing = NewEmptyClusterResults(len(s.failures))
+	return s
+}
+
+func testNameClusterID(config *compiledcfg.ProjectConfig, failure *clustering.Failure) clustering.ClusterID {
+	alg := &testname.Algorithm{}
+	return clustering.ClusterID{
+		Algorithm: testname.AlgorithmName,
+		ID:        hex.EncodeToString(alg.Cluster(config, failure)),
+	}
+}
+
+func failureReasonClusterID(config *compiledcfg.ProjectConfig, failure *clustering.Failure) clustering.ClusterID {
+	alg := &failurereason.Algorithm{}
+	return clustering.ClusterID{
+		Algorithm: failurereason.AlgorithmName,
+		ID:        hex.EncodeToString(alg.Cluster(config, failure)),
+	}
+}
+
+func ruleClusterID(ruleID string) clustering.ClusterID {
+	return clustering.ClusterID{
+		Algorithm: rulesalgorithm.AlgorithmName,
+		ID:        ruleID,
+	}
+}
+
+// diffClusters checks actual and expected clusters are equivalent (after
+// accounting for ordering differences). If not, a message explaining
+// the differences is returned.
+func diffClusters(actual [][]clustering.ClusterID, expected [][]clustering.ClusterID) string {
+	if len(actual) != len(expected) {
+		return fmt.Sprintf("got clusters for %v test results; want %v", len(actual), len(expected))
+	}
+	for i, actualClusters := range actual {
+		expectedClusters := expected[i]
+		expectedClusterSet := make(map[string]struct{})
+		for _, e := range expectedClusters {
+			expectedClusterSet[e.Key()] = struct{}{}
+		}
+
+		actualClusterSet := make(map[string]struct{})
+		for _, e := range actualClusters {
+			actualClusterSet[e.Key()] = struct{}{}
+		}
+		for j, a := range actualClusters {
+			if _, ok := expectedClusterSet[a.Key()]; ok {
+				delete(expectedClusterSet, a.Key())
+			} else {
+				return fmt.Sprintf("actual clusters for test result %v includes cluster %v at position %v, which is not expected", i, a.Key(), j)
+			}
+		}
+		if len(expectedClusterSet) > 0 {
+			var missingClusters []string
+			for c := range expectedClusterSet {
+				missingClusters = append(missingClusters, c)
+			}
+			return fmt.Sprintf("actual clusters for test result %v is missing cluster(s): %s", i, strings.Join(missingClusters, ", "))
+		}
+	}
+	return ""
+}
diff --git a/analysis/internal/clustering/algorithms/failurereason/failurereason.go b/analysis/internal/clustering/algorithms/failurereason/failurereason.go
new file mode 100644
index 0000000..5614655
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/failurereason/failurereason.go
@@ -0,0 +1,160 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package failurereason contains the failure reason clustering algorithm
+// for Weetbix.
+//
+// This algorithm removes ips, temp file names, numbers and other such tokens
+// to cluster similar reasons together.
+package failurereason
+
+import (
+	"bytes"
+	"crypto/sha256"
+	"errors"
+	"fmt"
+	"regexp"
+	"strconv"
+	"strings"
+	"text/template"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+)
+
+// AlgorithmVersion is the version of the clustering algorithm. The algorithm
+// version should be incremented whenever existing test results may be
+// clustered differently (i.e. Cluster(f) returns a different value for some
+// f that may have been already ingested).
+const AlgorithmVersion = 4
+
+// AlgorithmName is the identifier for the clustering algorithm.
+// Weetbix requires all clustering algorithms to have a unique identifier.
+// Must match the pattern ^[a-z0-9-.]{1,32}$.
+//
+// The AlgorithmName must encode the algorithm version, so that each version
+// of an algorithm has a different name.
+var AlgorithmName = fmt.Sprintf("%sv%v", clustering.FailureReasonAlgorithmPrefix, AlgorithmVersion)
+
+// BugTemplate is the template for the content of bugs created for failure
+// reason clusters. A list of test IDs is included to improve searchability
+// by test name.
+var BugTemplate = template.Must(template.New("reasonTemplate").Parse(
+	`This bug is for all test failures where the primary error message is similiar to the following (ignoring numbers and hexadecimal values):
+{{.FailureReason}}
+
+The following test(s) were observed to have matching failures at this time (at most five examples listed):
+{{range .TestIDs}}- {{.}}
+{{end}}`))
+
+// To match any 1 or more digit numbers, or hex values (often appear in temp
+// file names or prints of pointers), which will be replaced.
+var clusterExp = regexp.MustCompile(`[/+0-9a-zA-Z]{10,}=+|[\-0-9a-fA-F \t]{16,}|[0-9a-fA-Fx]{8,}|[0-9]+`)
+
+// Algorithm represents an instance of the reason-based clustering
+// algorithm.
+type Algorithm struct{}
+
+// Name returns the identifier of the clustering algorithm.
+func (a *Algorithm) Name() string {
+	return AlgorithmName
+}
+
+// clusterKey returns the unhashed key for the cluster. Absent an extremely
+// unlikely hash collision, this value is the same for all test results
+// in the cluster.
+func clusterKey(primaryErrorMessage string) string {
+	// Replace numbers and hex values.
+	return clusterExp.ReplaceAllString(primaryErrorMessage, "%")
+}
+
+// Cluster clusters the given test failure and returns its cluster ID (if it
+// can be clustered) or nil otherwise.
+func (a *Algorithm) Cluster(config *compiledcfg.ProjectConfig, failure *clustering.Failure) []byte {
+	if failure.Reason == nil || failure.Reason.PrimaryErrorMessage == "" {
+		return nil
+	}
+	id := clusterKey(failure.Reason.PrimaryErrorMessage)
+	// sha256 hash the resulting string.
+	h := sha256.Sum256([]byte(id))
+	// Take first 16 bytes as the ID. (Risk of collision is
+	// so low as to not warrant full 32 bytes.)
+	return h[0:16]
+}
+
+// ClusterDescription returns a description of the cluster, for use when
+// filing bugs, with the help of the given example failure.
+func (a *Algorithm) ClusterDescription(config *compiledcfg.ProjectConfig, summary *clustering.ClusterSummary) (*clustering.ClusterDescription, error) {
+	if summary.Example.Reason == nil || summary.Example.Reason.PrimaryErrorMessage == "" {
+		return nil, errors.New("cluster summary must contain example with failure reason")
+	}
+	type templateData struct {
+		FailureReason string
+		TestIDs       []string
+	}
+	var input templateData
+
+	// Quote and escape.
+	primaryError := strconv.QuoteToGraphic(summary.Example.Reason.PrimaryErrorMessage)
+	// Unquote, so we are left with the escaped error message only.
+	primaryError = primaryError[1 : len(primaryError)-1]
+
+	input.FailureReason = primaryError
+	for _, t := range summary.TopTests {
+		input.TestIDs = append(input.TestIDs, clustering.EscapeToGraphical(t))
+	}
+	var b bytes.Buffer
+	if err := BugTemplate.Execute(&b, input); err != nil {
+		return nil, err
+	}
+
+	return &clustering.ClusterDescription{
+		Title:       primaryError,
+		Description: b.String(),
+	}, nil
+}
+
+// ClusterKey returns the unhashed clustering key which is common
+// across all test results in a cluster. For display on the cluster
+// page or cluster listing.
+func (a *Algorithm) ClusterKey(config *compiledcfg.ProjectConfig, example *clustering.Failure) string {
+	if example.Reason == nil || example.Reason.PrimaryErrorMessage == "" {
+		return ""
+	}
+	// Should match exactly the algorithm in Cluster(...)
+	key := clusterKey(example.Reason.PrimaryErrorMessage)
+
+	return clustering.EscapeToGraphical(key)
+}
+
+// FailureAssociationRule returns a failure association rule that
+// captures the definition of cluster containing the given example.
+func (a *Algorithm) FailureAssociationRule(config *compiledcfg.ProjectConfig, example *clustering.Failure) string {
+	if example.Reason == nil || example.Reason.PrimaryErrorMessage == "" {
+		return ""
+	}
+	// Escape \, % and _ so that they are not interpreted by LIKE
+	// pattern matching.
+	rewriter := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
+	likePattern := rewriter.Replace(example.Reason.PrimaryErrorMessage)
+
+	// Replace hexadecimal sequences with wildcard matches. This is technically
+	// broader than our original cluster definition, but is more readable, and
+	// usually ends up matching the exact same set of failures.
+	likePattern = clusterExp.ReplaceAllString(likePattern, "%")
+
+	// Escape the pattern as a string literal.
+	stringLiteral := strconv.QuoteToGraphic(likePattern)
+	return fmt.Sprintf("reason LIKE %s", stringLiteral)
+}
diff --git a/analysis/internal/clustering/algorithms/failurereason/failurereason_test.go b/analysis/internal/clustering/algorithms/failurereason/failurereason_test.go
new file mode 100644
index 0000000..5735fcd
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/failurereason/failurereason_test.go
@@ -0,0 +1,181 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package failurereason
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestAlgorithm(t *testing.T) {
+	cfgpb := &configpb.ProjectConfig{}
+	Convey(`Name`, t, func() {
+		// Algorithm name should be valid.
+		a := &Algorithm{}
+		So(clustering.AlgorithmRe.MatchString(a.Name()), ShouldBeTrue)
+	})
+	Convey(`Cluster`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		Convey(`Does not cluster test result without failure reason`, func() {
+			id := a.Cluster(cfg, &clustering.Failure{})
+			So(id, ShouldBeNil)
+		})
+		Convey(`ID of appropriate length`, func() {
+			id := a.Cluster(cfg, &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "abcd this is a test failure message"},
+			})
+			// IDs may be 16 bytes at most.
+			So(len(id), ShouldBeGreaterThan, 0)
+			So(len(id), ShouldBeLessThanOrEqualTo, clustering.MaxClusterIDBytes)
+		})
+		Convey(`Same ID for same cluster with different numbers`, func() {
+			id1 := a.Cluster(cfg, &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Null pointer exception at ip 0x45637271"},
+			})
+			id2 := a.Cluster(cfg, &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Null pointer exception at ip 0x12345678"},
+			})
+			So(id2, ShouldResemble, id1)
+		})
+		Convey(`Different ID for different clusters`, func() {
+			id1 := a.Cluster(cfg, &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Exception in TestMethod"},
+			})
+			id2 := a.Cluster(cfg, &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Exception in MethodUnderTest"},
+			})
+			So(id2, ShouldNotResemble, id1)
+		})
+	})
+	Convey(`Failure Association Rule`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		test := func(failure *clustering.Failure, expectedRule string) {
+			rule := a.FailureAssociationRule(cfg, failure)
+			So(rule, ShouldEqual, expectedRule)
+
+			// Test the rule is valid syntax and matches at least the example failure.
+			expr, err := lang.Parse(rule)
+			So(err, ShouldBeNil)
+			So(expr.Evaluate(failure), ShouldBeTrue)
+		}
+		Convey(`Hexadecimal`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Null pointer exception at ip 0x45637271"},
+			}
+			test(failure, `reason LIKE "Null pointer exception at ip %"`)
+		})
+		Convey(`Numeric`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Could not connect to 127.1.2.1: connection refused"},
+			}
+			test(failure, `reason LIKE "Could not connect to %.%.%.%: connection refused"`)
+		})
+		Convey(`Base64`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Received unexpected response: AdafdxAAD17917+/="},
+			}
+			test(failure, `reason LIKE "Received unexpected response: %"`)
+		})
+		Convey(`Escaping`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: `_%"'+[]|` + "\u0000\r\n\v\u202E\u2066 AdafdxAAD17917+/="},
+			}
+			test(failure, `reason LIKE "\\_\\%\"'+[]|\x00\r\n\v\u202e\u2066 %"`)
+		})
+		Convey(`Multiline`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{
+					// Previously "ce\n ... Ac" matched the hexadecimal format
+					// for hexadecimal strings of 16 characters or more.
+					PrimaryErrorMessage: "Expected: to be called once\n          Actual: never called",
+				},
+			}
+			test(failure, `reason LIKE "Expected: to be called once\n          Actual: never called"`)
+		})
+	})
+	Convey(`Cluster Title`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		Convey(`Baseline`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Null pointer exception at ip 0x45637271"},
+			}
+			title := a.ClusterKey(cfg, failure)
+			So(title, ShouldEqual, `Null pointer exception at ip %`)
+		})
+		Convey(`Escaping`, func() {
+			failure := &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: `_%"'+[]|` + "\u0000\r\n\v\u202E\u2066 AdafdxAAD17917+/="},
+			}
+			title := a.ClusterKey(cfg, failure)
+			So(title, ShouldEqual, `_%\"'+[]|\x00\r\n\v\u202e\u2066 %`)
+		})
+	})
+	Convey(`Cluster Description`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		Convey(`Baseline`, func() {
+			failure := &clustering.ClusterSummary{
+				Example: clustering.Failure{
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "Null pointer exception at ip 0x45637271"},
+				},
+				TopTests: []string{
+					"ninja://test_one",
+					"ninja://test_two",
+					"ninja://test_three",
+				},
+			}
+			description, err := a.ClusterDescription(cfg, failure)
+			So(err, ShouldBeNil)
+			So(description.Title, ShouldEqual, `Null pointer exception at ip 0x45637271`)
+			So(description.Description, ShouldContainSubstring, `Null pointer exception at ip 0x45637271`)
+			So(description.Description, ShouldContainSubstring, `- ninja://test_one`)
+			So(description.Description, ShouldContainSubstring, `- ninja://test_three`)
+			So(description.Description, ShouldContainSubstring, `- ninja://test_three`)
+		})
+		Convey(`Escaping`, func() {
+			summary := &clustering.ClusterSummary{
+				Example: clustering.Failure{
+					Reason: &pb.FailureReason{PrimaryErrorMessage: `_%"'+[]|` + "\u0000\r\n\v\u202E\u2066 AdafdxAAD17917+/="},
+				},
+				TopTests: []string{
+					"\u2066\u202E\v\n\r\u0000",
+				},
+			}
+			description, err := a.ClusterDescription(cfg, summary)
+			So(err, ShouldBeNil)
+			So(description.Title, ShouldEqual, `_%\"'+[]|\x00\r\n\v\u202e\u2066 AdafdxAAD17917+/=`)
+			So(description.Description, ShouldContainSubstring, `_%\"'+[]|\x00\r\n\v\u202e\u2066 AdafdxAAD17917+/=`)
+			So(description.Description, ShouldContainSubstring, `- \u2066\u202e\v\n\r\x00`)
+		})
+	})
+}
diff --git a/analysis/internal/clustering/algorithms/rulesalgorithm/rules.go b/analysis/internal/clustering/algorithms/rulesalgorithm/rules.go
new file mode 100644
index 0000000..29771cf
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/rulesalgorithm/rules.go
@@ -0,0 +1,74 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rulesalgorithm
+
+import (
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+)
+
+type Algorithm struct{}
+
+// AlgorithmVersion is the version of the clustering algorithm. The algorithm
+// version should be incremented whenever existing test results may be
+// clustered differently (i.e. Cluster(f) returns a different value for some
+// f that may have been already ingested).
+const AlgorithmVersion = 2
+
+// AlgorithmName is the identifier for the clustering algorithm.
+// Weetbix requires all clustering algorithms to have a unique identifier.
+// Must match the pattern ^[a-z0-9-.]{1,32}$.
+//
+// The AlgorithmName must encode the algorithm version, so that each version
+// of an algorithm has a different name.
+var AlgorithmName = fmt.Sprintf("%sv%v", clustering.RulesAlgorithmPrefix, AlgorithmVersion)
+
+// Cluster incrementally (re-)clusters the given test failure, updating the
+// matched cluster IDs. The passed existingRulesVersion and ruleIDs
+// should be the ruleset.RulesVersion and cluster IDs of the previous call
+// to Cluster (if any) from which incremental clustering should occur.
+//
+// If clustering has not been performed previously, and clustering is to be
+// performed from scratch, existingRulesVersion should be rules.StartingEpoch
+// and ruleIDs should be an empty list.
+//
+// This method is on the performance-critical path for re-clustering.
+//
+// To avoid unnecessary allocations, the method will modify the passed ruleIDs.
+func (a *Algorithm) Cluster(ruleset *cache.Ruleset, existingRulesVersion time.Time, ruleIDs map[string]struct{}, failure *clustering.Failure) {
+	for id := range ruleIDs {
+		// Remove matches with rules that are no longer active.
+		if !ruleset.IsRuleActive(id) {
+			delete(ruleIDs, id)
+		}
+	}
+
+	// For efficiency, only match new/modified rules since the
+	// last call to Cluster(...).
+	newRules := ruleset.ActiveRulesWithPredicateUpdatedSince(existingRulesVersion)
+	for _, r := range newRules {
+		if r.Expr.Evaluate(failure) {
+			ruleIDs[r.Rule.RuleID] = struct{}{}
+		} else {
+			// If this is a modified rule (rather than a new rule)
+			// it may have matched previously. Delete any existing
+			// match.
+			delete(ruleIDs, r.Rule.RuleID)
+		}
+	}
+}
diff --git a/analysis/internal/clustering/algorithms/rulesalgorithm/rules_test.go b/analysis/internal/clustering/algorithms/rulesalgorithm/rules_test.go
new file mode 100644
index 0000000..2bb4f9f
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/rulesalgorithm/rules_test.go
@@ -0,0 +1,152 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rulesalgorithm
+
+import (
+	"sort"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestAlgorithm(t *testing.T) {
+	Convey(`Name`, t, func() {
+		// Algorithm name should be valid.
+		So(clustering.AlgorithmRe.MatchString(AlgorithmName), ShouldBeTrue)
+	})
+	Convey(`Cluster from scratch`, t, func() {
+		a := &Algorithm{}
+		existingRulesVersion := rules.StartingEpoch
+		ids := make(map[string]struct{})
+		Convey(`Empty Rules`, func() {
+			ruleset := &cache.Ruleset{}
+			a.Cluster(ruleset, existingRulesVersion, ids, &clustering.Failure{
+				Reason: &pb.FailureReason{PrimaryErrorMessage: "Null pointer exception at ip 0x45637271"},
+			})
+			So(ids, ShouldBeEmpty)
+		})
+		Convey(`With Rules`, func() {
+			rule1, err := cache.NewCachedRule(
+				rules.NewRule(100).
+					WithRuleDefinition(`test = "ninja://test_name_one/"`).
+					Build())
+			So(err, ShouldBeNil)
+			rule2, err := cache.NewCachedRule(
+				rules.NewRule(101).
+					WithRuleDefinition(`reason LIKE "failed to connect to %.%.%.%"`).
+					Build())
+			So(err, ShouldBeNil)
+
+			rulesVersion := rules.Version{
+				Predicates: time.Now(),
+			}
+			lastUpdated := time.Now()
+			rules := []*cache.CachedRule{rule1, rule2}
+			ruleset := cache.NewRuleset("myproject", rules, rulesVersion, lastUpdated)
+
+			Convey(`Without failure reason`, func() {
+				Convey(`Matching`, func() {
+					a.Cluster(ruleset, existingRulesVersion, ids, &clustering.Failure{
+						TestID: "ninja://test_name_one/",
+					})
+					So(ids, ShouldResemble, map[string]struct{}{
+						rule1.Rule.RuleID: {},
+					})
+				})
+				Convey(`Non-matcing`, func() {
+					a.Cluster(ruleset, existingRulesVersion, ids, &clustering.Failure{
+						TestID: "ninja://test_name_two/",
+					})
+					So(ids, ShouldBeEmpty)
+				})
+			})
+			Convey(`Matches one`, func() {
+				a.Cluster(ruleset, existingRulesVersion, ids, &clustering.Failure{
+					TestID: "ninja://test_name_three/",
+					Reason: &pb.FailureReason{
+						PrimaryErrorMessage: "failed to connect to 192.168.0.1",
+					},
+				})
+				So(ids, ShouldResemble, map[string]struct{}{
+					rule2.Rule.RuleID: {},
+				})
+			})
+			Convey(`Matches multiple`, func() {
+				a.Cluster(ruleset, existingRulesVersion, ids, &clustering.Failure{
+					TestID: "ninja://test_name_one/",
+					Reason: &pb.FailureReason{
+						PrimaryErrorMessage: "failed to connect to 192.168.0.1",
+					},
+				})
+				expectedIDs := []string{rule1.Rule.RuleID, rule2.Rule.RuleID}
+				sort.Strings(expectedIDs)
+				So(ids, ShouldResemble, map[string]struct{}{
+					rule1.Rule.RuleID: {},
+					rule2.Rule.RuleID: {},
+				})
+			})
+		})
+	})
+	Convey(`Cluster incrementally`, t, func() {
+		a := &Algorithm{}
+		originalRulesVersion := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+		testFailure := &clustering.Failure{
+			TestID: "ninja://test_name_one/",
+			Reason: &pb.FailureReason{
+				PrimaryErrorMessage: "failed to connect to 192.168.0.1",
+			},
+		}
+
+		// The ruleset we are incrementally clustering with has a new rule
+		// (rule 3) and no longer has rule 2. We silently set the definition
+		// of rule1 to FALSE without changing its last updated time (this
+		// should never happen in reality) to check it is never evaluated.
+		rule1, err := cache.NewCachedRule(
+			rules.NewRule(100).WithRuleDefinition(`FALSE`).
+				WithPredicateLastUpdated(originalRulesVersion).Build())
+		So(err, ShouldBeNil)
+		rule3, err := cache.NewCachedRule(
+			rules.NewRule(102).
+				WithRuleDefinition(`reason LIKE "failed to connect to %"`).
+				WithPredicateLastUpdated(originalRulesVersion.Add(time.Hour)).Build())
+		So(err, ShouldBeNil)
+
+		rs := []*cache.CachedRule{rule1, rule3}
+		newRulesVersion := rules.Version{
+			Predicates: originalRulesVersion.Add(time.Hour),
+		}
+		lastUpdated := time.Now()
+		secondRuleset := cache.NewRuleset("myproject", rs, newRulesVersion, lastUpdated)
+
+		ids := map[string]struct{}{
+			rule1.Rule.RuleID: {},
+			"rule2-id":        {},
+		}
+
+		// Test incrementally clustering leads to the correct outcome,
+		// matching rule 3 and unmatching rule 2.
+		a.Cluster(secondRuleset, originalRulesVersion, ids, testFailure)
+		So(ids, ShouldResemble, map[string]struct{}{
+			rule1.Rule.RuleID: {},
+			rule3.Rule.RuleID: {},
+		})
+	})
+}
diff --git a/analysis/internal/clustering/algorithms/testdata.go b/analysis/internal/clustering/algorithms/testdata.go
new file mode 100644
index 0000000..5696d79
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/testdata.go
@@ -0,0 +1,31 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package algorithms
+
+import configpb "go.chromium.org/luci/analysis/proto/config"
+
+// TestClusteringConfig returns standard clustering configuration
+// that can be used for testing.
+func TestClusteringConfig() *configpb.Clustering {
+	return &configpb.Clustering{
+		TestNameRules: []*configpb.TestNameClusteringRule{
+			{
+				Name:         "Google Test (Value-parameterized)",
+				Pattern:      `^ninja://test_name/1[0-9]*$`,
+				LikeTemplate: `ninja://test_name/1%`,
+			},
+		},
+	}
+}
diff --git a/analysis/internal/clustering/algorithms/testname/rules/rule.go b/analysis/internal/clustering/algorithms/testname/rules/rule.go
new file mode 100644
index 0000000..87258f8
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/testname/rules/rule.go
@@ -0,0 +1,191 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package rules provides methods to evaluate test name clustering rules.
+package rules
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// likeRewriter escapes usages of '\', '%' and '_', so that
+// the original text is interpreted literally in a LIKE
+// expression.
+var likeRewriter = strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
+
+// substitutionRE matches use of the '$' operator which
+// may be used in templates to substitute in values.
+// Captured usages are either:
+// - ${name}, which tells the template to insert the value
+//   of the capture group with that name.
+// - $$, which tells the template insert a literal '$'
+//   into the output.
+// - $, which indicates an invalid use of the '$' operator
+//   ($ not followed by $ or {name}).
+var substitutionRE = regexp.MustCompile(`\$\{(\w+?)\}|\$\$?`)
+
+// Evaluator evaluates a test name clustering rule on
+// a test name, returning whether the rule matches and
+// if so, the LIKE expression that defines the cluster.
+type Evaluator func(testName string) (like string, ok bool)
+
+// Compile produces a RuleEvaluator that can quickly evaluate
+// whether a given test name matches the given test name
+// clustering rule, and if so, return the test name LIKE
+// expression that defines the cluster.
+//
+// As Compiling rules is slow, the result should be cached.
+func Compile(rule *configpb.TestNameClusteringRule) (Evaluator, error) {
+	re, err := regexp.Compile(rule.Pattern)
+	if err != nil {
+		return nil, errors.Annotate(err, "parsing pattern").Err()
+	}
+
+	// Segments defines portions of the output LIKE expression,
+	// which are either literal text found in the LikeTemplate,
+	// or parts of the test name matched by Pattern.
+	var segments []segment
+
+	// The exclusive upper bound we have created segments for.
+	lastIndex := 0
+
+	// Analyze the specified LikeTemplate to identify the
+	// location of all substitution expressions (of the form ${name})
+	// and iterate through them.
+	matches := substitutionRE.FindAllStringSubmatchIndex(rule.LikeTemplate, -1)
+	for _, match := range matches {
+		// The start and end of the substitution expression (of the form ${name})
+		// in c.LikeTemplate.
+		matchStart := match[0]
+		matchEnd := match[1]
+
+		if matchStart > lastIndex {
+			// There is some literal text between the start of the LikeTemplate
+			// and the first substitution expression, or the last substitution
+			// expression and the current one. This is literal
+			// text that should be included in the output directly.
+			literalText := rule.LikeTemplate[lastIndex:matchStart]
+			if err := lang.ValidateLikePattern(literalText); err != nil {
+				return nil, errors.Annotate(err, "%q is not a valid standalone LIKE expression", literalText).Err()
+			}
+			segments = append(segments, &literalSegment{
+				value: literalText,
+			})
+		}
+
+		matchString := rule.LikeTemplate[match[0]:match[1]]
+		if matchString == "$" {
+			return nil, fmt.Errorf("invalid use of the $ operator at position %v in %q ('$' not followed by '{name}' or '$'), "+
+				"if you meant to include a literal $ character, please use $$", match[0], rule.LikeTemplate)
+		}
+		if matchString == "$$" {
+			// Insert the literal "$" into the output.
+			segments = append(segments, &literalSegment{
+				value: "$",
+			})
+		} else {
+			// The name of the capture group that should be substituted at
+			// the current position.
+			name := rule.LikeTemplate[match[2]:match[3]]
+
+			// Find the index of the corresponding capture group in the
+			// Pattern.
+			submatchIndex := -1
+			for i, submatchName := range re.SubexpNames() {
+				if submatchName == "" {
+					// Unnamed capturing groups can not be referred to.
+					continue
+				}
+				if submatchName == name {
+					submatchIndex = i
+					break
+				}
+			}
+			if submatchIndex == -1 {
+				return nil, fmt.Errorf("like template contains reference to non-existant capturing group with name %q", name)
+			}
+
+			// Indicate we should include the value of that capture group
+			// in the output.
+			segments = append(segments, &submatchSegment{
+				submatchIndex: submatchIndex,
+			})
+		}
+		lastIndex = matchEnd
+	}
+
+	if lastIndex < len(rule.LikeTemplate) {
+		literalText := rule.LikeTemplate[lastIndex:len(rule.LikeTemplate)]
+		if err := lang.ValidateLikePattern(literalText); err != nil {
+			return nil, errors.Annotate(err, "%q is not a valid standalone LIKE expression", literalText).Err()
+		}
+		// Some text after all substitution expressions. This is literal
+		// text that should be included in the output directly.
+		segments = append(segments, &literalSegment{
+			value: literalText,
+		})
+	}
+
+	// Produce the evaluator. This is in the hot-path that is run
+	// on every test result on every ingestion or config change,
+	// so it should be fast. We do not want to be parsing regular
+	// expressions or templates in here.
+	evaluator := func(testName string) (like string, ok bool) {
+		m := re.FindStringSubmatch(testName)
+		if m == nil {
+			return "", false
+		}
+		segmentValues := make([]string, len(segments))
+		for i, s := range segments {
+			segmentValues[i] = s.evaluate(m)
+		}
+		return strings.Join(segmentValues, ""), true
+	}
+	return evaluator, nil
+}
+
+// literalSegment is a part of a constructed string
+// that is a constant string value.
+type literalSegment struct {
+	// The literal value that defines this segment.
+	value string
+}
+
+func (c *literalSegment) evaluate(matches []string) string {
+	return c.value
+}
+
+// submatchSegment is a part of a constructed string
+// that is populated with a matched portion of
+// another source string.
+type submatchSegment struct {
+	// The source string submatch index that defines this segment.
+	submatchIndex int
+}
+
+func (m *submatchSegment) evaluate(matches []string) string {
+	return likeRewriter.Replace(matches[m.submatchIndex])
+}
+
+// segment represents a part of a constructed string.
+type segment interface {
+	evaluate(matches []string) string
+}
diff --git a/analysis/internal/clustering/algorithms/testname/rules/rule_test.go b/analysis/internal/clustering/algorithms/testname/rules/rule_test.go
new file mode 100644
index 0000000..b38f933
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/testname/rules/rule_test.go
@@ -0,0 +1,161 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rules
+
+import (
+	"testing"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestRule(t *testing.T) {
+	Convey(`Evaluate`, t, func() {
+		Convey(`Valid Examples`, func() {
+			Convey(`Blink Web Tests`, func() {
+				rule := &configpb.TestNameClusteringRule{
+					Name:         "Blink Web Tests",
+					Pattern:      `^ninja://:blink_web_tests/(virtual/[^/]+/)?(?P<testname>([^/]+/)+[^/]+\.[a-zA-Z]+).*$`,
+					LikeTemplate: `ninja://:blink\_web\_tests/%${testname}%`,
+				}
+				eval, err := Compile(rule)
+				So(err, ShouldBeNil)
+
+				inputs := []string{
+					"ninja://:blink_web_tests/virtual/oopr-canvas2d/fast/canvas/canvas-getImageData.html",
+					"ninja://:blink_web_tests/virtual/oopr-canvas2d/fast/canvas/canvas-getImageData.html?param=a",
+					"ninja://:blink_web_tests/virtual/oopr-canvas3d/fast/canvas/canvas-getImageData.html?param=b",
+					"ninja://:blink_web_tests/fast/canvas/canvas-getImageData.html",
+				}
+				for _, testname := range inputs {
+					like, ok := eval(testname)
+					So(ok, ShouldBeTrue)
+					So(like, ShouldEqual, `ninja://:blink\_web\_tests/%fast/canvas/canvas-getImageData.html%`)
+				}
+
+				_, ok := eval("ninja://:not_blink_web_tests/fast/canvas/canvas-getImageData.html")
+				So(ok, ShouldBeFalse)
+			})
+			Convey(`Google Tests`, func() {
+				rule := &configpb.TestNameClusteringRule{
+					Name: "Google Test (Value-parameterized)",
+					// E.g. ninja:{target}/Prefix/ColorSpaceTest.testNullTransform/11
+					// Note that "Prefix/" portion may be blank/omitted.
+					Pattern:      `^ninja:(?P<target>[\w/]+:\w+)/(\w+/)?(?P<suite>\w+)\.(?P<case>\w+)/\w+$`,
+					LikeTemplate: `ninja:${target}/%${suite}.${case}%`,
+				}
+				eval, err := Compile(rule)
+				So(err, ShouldBeNil)
+
+				inputs := []string{
+					"ninja://chrome/test:interactive_ui_tests/Name/ColorSpaceTest.testNullTransform/0",
+					"ninja://chrome/test:interactive_ui_tests/Name/ColorSpaceTest.testNullTransform/0",
+					"ninja://chrome/test:interactive_ui_tests/Name/ColorSpaceTest.testNullTransform/11",
+				}
+				for _, testname := range inputs {
+					like, ok := eval(testname)
+					So(ok, ShouldBeTrue)
+					So(like, ShouldEqual, "ninja://chrome/test:interactive\\_ui\\_tests/%ColorSpaceTest.testNullTransform%")
+				}
+
+				_, ok := eval("ninja://:blink_web_tests/virtual/oopr-canvas2d/fast/canvas/canvas-getImageData.html")
+				So(ok, ShouldBeFalse)
+			})
+		})
+		Convey(`Test name escaping in LIKE output`, func() {
+			Convey(`Test name is escaped when substituted`, func() {
+				rule := &configpb.TestNameClusteringRule{
+					Name:         "Escape test",
+					Pattern:      `^(?P<testname>.*)$`,
+					LikeTemplate: `${testname}_%`,
+				}
+				eval, err := Compile(rule)
+				So(err, ShouldBeNil)
+
+				// Verify that the test name is not injected varbatim in the generated
+				// like expression, but is escaped to avoid it being interpreted.
+				like, ok := eval(`_\%`)
+				So(ok, ShouldBeTrue)
+				So(like, ShouldEqual, `\_\\\%_%`)
+			})
+			Convey(`Unsafe LIKE templates are rejected`, func() {
+				rule := &configpb.TestNameClusteringRule{
+					Name:    "Escape test",
+					Pattern: `^path\\(?P<testname>.*)$`,
+					// The user as incorrectly used an unfinished LIKE escape sequence
+					// (with trailing '\') before the testname substitution.
+					// If substitution were allowed, this may allow the test name to be
+					// interpreted as a LIKE expression instead as literal text.
+					// E.g. a test name of `path\%` may yield `path\\%` after template
+					// evaluation which invokes the LIKE '%' operator.
+					LikeTemplate: `path\${testname}`,
+				}
+				_, err := Compile(rule)
+				So(err, ShouldErrLike, `"path\\" is not a valid standalone LIKE expression: unfinished escape sequence "\" at end of LIKE pattern`)
+			})
+		})
+		Convey(`Substitution operator`, func() {
+			Convey(`Dollar sign can be inserted into output`, func() {
+				rule := &configpb.TestNameClusteringRule{
+					Name:         "Insert $",
+					Pattern:      `^(?P<testname>.*)$`,
+					LikeTemplate: `${testname}$$blah$$$$`,
+				}
+				eval, err := Compile(rule)
+				So(err, ShouldBeNil)
+
+				like, ok := eval(`test`)
+				So(ok, ShouldBeTrue)
+				So(like, ShouldEqual, `test$blah$$`)
+			})
+			Convey(`Invalid uses of substitution operator are rejected`, func() {
+				rule := &configpb.TestNameClusteringRule{
+					Name:         "Invalid use of $ (neither $$ or ${name})",
+					Pattern:      `^(?P<testname>.*)$`,
+					LikeTemplate: `${testname}blah$$$`,
+				}
+				_, err := Compile(rule)
+				So(err, ShouldErrLike, `invalid use of the $ operator at position 17 in "${testname}blah$$$"`)
+
+				rule = &configpb.TestNameClusteringRule{
+					Name:         "Invalid use of $ (invalid capture group name)",
+					Pattern:      `^(?P<testname>.*)$`,
+					LikeTemplate: `${template@}blah`,
+				}
+				_, err = Compile(rule)
+				So(err, ShouldErrLike, `invalid use of the $ operator at position 0 in "${template@}blah"`)
+
+				rule = &configpb.TestNameClusteringRule{
+					Name:         "Capture group name not defined",
+					Pattern:      `^(?P<testname>.*)$`,
+					LikeTemplate: `${myname}blah`,
+				}
+				_, err = Compile(rule)
+				So(err, ShouldErrLike, `like template contains reference to non-existant capturing group with name "myname"`)
+			})
+		})
+		Convey(`Invalid Pattern`, func() {
+			rule := &configpb.TestNameClusteringRule{
+				Name:         "Invalid Pattern",
+				Pattern:      `[`,
+				LikeTemplate: ``,
+			}
+			_, err := Compile(rule)
+			So(err, ShouldErrLike, `parsing pattern: error parsing regexp`)
+		})
+	})
+}
diff --git a/analysis/internal/clustering/algorithms/testname/testname.go b/analysis/internal/clustering/algorithms/testname/testname.go
new file mode 100644
index 0000000..cbe0f09
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/testname/testname.go
@@ -0,0 +1,134 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package testname contains the test name-based clustering algorithm for Weetbix.
+package testname
+
+import (
+	"crypto/sha256"
+	"fmt"
+	"strconv"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+)
+
+// AlgorithmVersion is the version of the clustering algorithm. The algorithm
+// version should be incremented whenever existing test results may be
+// clustered differently (i.e. Cluster(f) returns a different value for some
+// f that may have been already ingested).
+const AlgorithmVersion = 3
+
+// AlgorithmName is the identifier for the clustering algorithm.
+// Weetbix requires all clustering algorithms to have a unique identifier.
+// Must match the pattern ^[a-z0-9-.]{1,32}$.
+//
+// The AlgorithmName must encode the algorithm version, so that each version
+// of an algorithm has a different name.
+var AlgorithmName = fmt.Sprintf("%sv%v", clustering.TestNameAlgorithmPrefix, AlgorithmVersion)
+
+// Algorithm represents an instance of the test name-based clustering
+// algorithm.
+type Algorithm struct{}
+
+// Name returns the identifier of the clustering algorithm.
+func (a *Algorithm) Name() string {
+	return AlgorithmName
+}
+
+// clusterLike returns the test name LIKE expression that defines
+// the cluster the given test result belongs to.
+//
+// By default this LIKE expression encodes just the test
+// name itself. However, by using rules, projects can configure
+// it to mask out parts of the test name (e.g. corresponding
+// to test variants).
+// "ninja://chrome/test:interactive_ui_tests/ColorSpaceTest.testNullTransform/%"
+func clusterLike(config *compiledcfg.ProjectConfig, failure *clustering.Failure) (like string, ok bool) {
+	testID := failure.TestID
+	for _, r := range config.TestNameRules {
+		like, ok := r(testID)
+		if ok {
+			return like, true
+		}
+	}
+	// No rule matches. Match the test name literally.
+	return "", false
+}
+
+// clusterKey returns the unhashed key for the cluster. Absent an extremely
+// unlikely hash collision, this value is the same for all test results
+// in the cluster.
+func clusterKey(config *compiledcfg.ProjectConfig, failure *clustering.Failure) string {
+	// Get the like expression that defines the cluster.
+	key, ok := clusterLike(config, failure)
+	if !ok {
+		// Fall back to clustering on the exact test name.
+		key = failure.TestID
+	}
+	return key
+}
+
+// Cluster clusters the given test failure and returns its cluster ID (if it
+// can be clustered) or nil otherwise.
+func (a *Algorithm) Cluster(config *compiledcfg.ProjectConfig, failure *clustering.Failure) []byte {
+	key := clusterKey(config, failure)
+
+	// Hash the expressionto generate a unique fingerprint.
+	h := sha256.Sum256([]byte(key))
+	// Take first 16 bytes as the ID. (Risk of collision is
+	// so low as to not warrant full 32 bytes.)
+	return h[0:16]
+}
+
+const bugDescriptionTemplateLike = `This bug is for all test failures with a test name like: %s`
+const bugDescriptionTemplateExact = `This bug is for all test failures with the test name: %s`
+
+// ClusterDescription returns a description of the cluster, for use when
+// filing bugs, with the help of the given example failure.
+func (a *Algorithm) ClusterDescription(config *compiledcfg.ProjectConfig, summary *clustering.ClusterSummary) (*clustering.ClusterDescription, error) {
+	// Get the like expression that defines the cluster.
+	like, ok := clusterLike(config, &summary.Example)
+	if ok {
+		return &clustering.ClusterDescription{
+			Title:       clustering.EscapeToGraphical(like),
+			Description: fmt.Sprintf(bugDescriptionTemplateLike, clustering.EscapeToGraphical(like)),
+		}, nil
+	} else {
+		// No matching clustering rule. Fall back to the exact test name.
+		return &clustering.ClusterDescription{
+			Title:       clustering.EscapeToGraphical(summary.Example.TestID),
+			Description: fmt.Sprintf(bugDescriptionTemplateExact, clustering.EscapeToGraphical(summary.Example.TestID)),
+		}, nil
+	}
+}
+
+// ClusterKey returns the unhashed clustering key which is common
+// across all test results in a cluster. For display on the cluster
+// page or cluster listing.
+func (a *Algorithm) ClusterKey(config *compiledcfg.ProjectConfig, example *clustering.Failure) string {
+	key := clusterKey(config, example)
+	return clustering.EscapeToGraphical(key)
+}
+
+// FailureAssociationRule returns a failure association rule that
+// captures the definition of cluster containing the given example.
+func (a *Algorithm) FailureAssociationRule(config *compiledcfg.ProjectConfig, example *clustering.Failure) string {
+	like, ok := clusterLike(config, example)
+	if ok {
+		return fmt.Sprintf("test LIKE %s", strconv.QuoteToGraphic(like))
+	} else {
+		return fmt.Sprintf("test = %s", strconv.QuoteToGraphic(example.TestID))
+	}
+}
diff --git a/analysis/internal/clustering/algorithms/testname/testname_test.go b/analysis/internal/clustering/algorithms/testname/testname_test.go
new file mode 100644
index 0000000..be403d5
--- /dev/null
+++ b/analysis/internal/clustering/algorithms/testname/testname_test.go
@@ -0,0 +1,195 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testname
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestAlgorithm(t *testing.T) {
+	rules := []*configpb.TestNameClusteringRule{
+		{
+			Name:         "Blink Web Tests",
+			Pattern:      `^ninja://:blink_web_tests/(virtual/[^/]+/)?(?P<testname>([^/]+/)+[^/]+\.[a-zA-Z]+).*$`,
+			LikeTemplate: "ninja://:blink\\_web\\_tests/%${testname}%",
+		},
+	}
+	cfgpb := &configpb.ProjectConfig{
+		Clustering: &configpb.Clustering{
+			TestNameRules: rules,
+		},
+	}
+
+	Convey(`Name`, t, func() {
+		// Algorithm name should be valid.
+		a := &Algorithm{}
+		So(clustering.AlgorithmRe.MatchString(a.Name()), ShouldBeTrue)
+	})
+	Convey(`Cluster`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		Convey(`ID of appropriate length`, func() {
+			id := a.Cluster(cfg, &clustering.Failure{
+				TestID: "ninja://test_name",
+			})
+			// IDs may be 16 bytes at most.
+			So(len(id), ShouldBeGreaterThan, 0)
+			So(len(id), ShouldBeLessThanOrEqualTo, clustering.MaxClusterIDBytes)
+		})
+		Convey(`Same ID for same test name`, func() {
+			Convey(`No matching rules`, func() {
+				id1 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://test_name_one/",
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "A"},
+				})
+				id2 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://test_name_one/",
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "B"},
+				})
+				So(id2, ShouldResemble, id1)
+			})
+			Convey(`Matching rules`, func() {
+				id1 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://:blink_web_tests/virtual/abc/folder/test-name.html",
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "A"},
+				})
+				id2 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://:blink_web_tests/folder/test-name.html?param=2",
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "B"},
+				})
+				So(id2, ShouldResemble, id1)
+			})
+		})
+		Convey(`Different ID for different clusters`, func() {
+			Convey(`No matching rules`, func() {
+				id1 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://test_name_one/",
+				})
+				id2 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://test_name_two/",
+				})
+				So(id2, ShouldNotResemble, id1)
+			})
+			Convey(`Matching rules`, func() {
+				id1 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://:blink_web_tests/virtual/abc/folder/test-name-a.html",
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "A"},
+				})
+				id2 := a.Cluster(cfg, &clustering.Failure{
+					TestID: "ninja://:blink_web_tests/folder/test-name-b.html?param=2",
+					Reason: &pb.FailureReason{PrimaryErrorMessage: "B"},
+				})
+				So(id2, ShouldNotResemble, id1)
+			})
+		})
+	})
+	Convey(`Failure Association Rule`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		test := func(failure *clustering.Failure, expectedRule string) {
+			rule := a.FailureAssociationRule(cfg, failure)
+			So(rule, ShouldEqual, expectedRule)
+
+			// Test the rule is valid syntax and matches at least the example failure.
+			expr, err := lang.Parse(rule)
+			So(err, ShouldBeNil)
+			So(expr.Evaluate(failure), ShouldBeTrue)
+		}
+		Convey(`No matching rules`, func() {
+			failure := &clustering.Failure{
+				TestID: "ninja://test_name_one/",
+			}
+			test(failure, `test = "ninja://test_name_one/"`)
+		})
+		Convey(`Matching rule`, func() {
+			failure := &clustering.Failure{
+				TestID: "ninja://:blink_web_tests/virtual/dark-color-scheme/fast/forms/color-scheme/select/select-multiple-hover-unselected.html",
+			}
+			test(failure, `test LIKE "ninja://:blink\\_web\\_tests/%fast/forms/color-scheme/select/select-multiple-hover-unselected.html%"`)
+		})
+		Convey(`Escapes LIKE syntax`, func() {
+			failure := &clustering.Failure{
+				TestID: `ninja://:blink_web_tests/a/b_\%c.html`,
+			}
+			test(failure, `test LIKE "ninja://:blink\\_web\\_tests/%a/b\\_\\\\\\%c.html%"`)
+		})
+		Convey(`Escapes non-graphic Unicode characters`, func() {
+			failure := &clustering.Failure{
+				TestID: "\u0000\r\n\v\u202E\u2066",
+			}
+			test(failure, `test = "\x00\r\n\v\u202e\u2066"`)
+		})
+	})
+	Convey(`Cluster Title`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		Convey(`No matching rules`, func() {
+			failure := &clustering.Failure{
+				TestID: "ninja://test_name_one",
+			}
+			title := a.ClusterKey(cfg, failure)
+			So(title, ShouldEqual, "ninja://test_name_one")
+		})
+		Convey(`Matching rule`, func() {
+			failure := &clustering.Failure{
+				TestID: "ninja://:blink_web_tests/virtual/dark-color-scheme/fast/forms/color-scheme/select/select-multiple-hover-unselected.html",
+			}
+			title := a.ClusterKey(cfg, failure)
+			So(title, ShouldEqual, `ninja://:blink\\_web\\_tests/%fast/forms/color-scheme/select/select-multiple-hover-unselected.html%`)
+		})
+	})
+	Convey(`Cluster Description`, t, func() {
+		a := &Algorithm{}
+		cfg, err := compiledcfg.NewConfig(cfgpb)
+		So(err, ShouldBeNil)
+
+		Convey(`No matching rules`, func() {
+			summary := &clustering.ClusterSummary{
+				Example: clustering.Failure{
+					TestID: "ninja://test_name_one",
+				},
+			}
+			description, err := a.ClusterDescription(cfg, summary)
+			So(err, ShouldBeNil)
+			So(description.Title, ShouldEqual, "ninja://test_name_one")
+			So(description.Description, ShouldContainSubstring, "ninja://test_name_one")
+		})
+		Convey(`Matching rule`, func() {
+			summary := &clustering.ClusterSummary{
+				Example: clustering.Failure{
+					TestID: "ninja://:blink_web_tests/virtual/dark-color-scheme/fast/forms/color-scheme/select/select-multiple-hover-unselected.html",
+				},
+			}
+			description, err := a.ClusterDescription(cfg, summary)
+			So(err, ShouldBeNil)
+			So(description.Title, ShouldEqual, `ninja://:blink\\_web\\_tests/%fast/forms/color-scheme/select/select-multiple-hover-unselected.html%`)
+			So(description.Description, ShouldContainSubstring, `ninja://:blink\\_web\\_tests/%fast/forms/color-scheme/select/select-multiple-hover-unselected.html%`)
+		})
+	})
+}
diff --git a/analysis/internal/clustering/chunkstore/client.go b/analysis/internal/clustering/chunkstore/client.go
new file mode 100644
index 0000000..2f96960
--- /dev/null
+++ b/analysis/internal/clustering/chunkstore/client.go
@@ -0,0 +1,187 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package chunkstore
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"fmt"
+	"io"
+	"regexp"
+
+	"cloud.google.com/go/storage"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/grpc/grpcmon"
+	"go.chromium.org/luci/server/auth"
+
+	"google.golang.org/api/option"
+	"google.golang.org/grpc"
+	"google.golang.org/protobuf/proto"
+
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/config"
+)
+
+// objectRe matches validly formed object IDs.
+var objectRe = regexp.MustCompile(`^[0-9a-f]{32}$`)
+
+// Client provides methods to store and retrieve chunks of test failures.
+type Client struct {
+	// client is the GCS client used to access chunks.
+	client *storage.Client
+	// bucket is the GCS bucket in which chunks are stored.
+	bucket string
+}
+
+// NewClient initialises a new chunk storage client, that uses the specified
+// GCS bucket as the backing store.
+func NewClient(ctx context.Context, bucket string) (*Client, error) {
+	// Credentials with Cloud scope.
+	creds, err := auth.GetPerRPCCredentials(ctx, auth.AsSelf, auth.WithScopes(auth.CloudOAuthScopes...))
+	if err != nil {
+		return nil, errors.Annotate(err, "failed to get PerRPCCredentials").Err()
+	}
+
+	// Initialize the client.
+	options := []option.ClientOption{
+		option.WithGRPCDialOption(grpc.WithPerRPCCredentials(creds)),
+		option.WithGRPCDialOption(grpcmon.WithClientRPCStatsMonitor()),
+		option.WithScopes(storage.ScopeReadWrite),
+	}
+	cl, err := storage.NewClient(ctx, options...)
+
+	if err != nil {
+		return nil, errors.Annotate(err, "failed to instantiate Cloud Storage client").Err()
+	}
+	return &Client{
+		client: cl,
+		bucket: bucket,
+	}, nil
+}
+
+// Close releases resources associated with the client.
+func (c *Client) Close() {
+	c.client.Close()
+}
+
+// Put saves the given chunk to storage. If successful, it returns
+// the randomly-assigned ID of the created object.
+func (c *Client) Put(ctx context.Context, project string, content *cpb.Chunk) (objectID string, retErr error) {
+	if err := validateProject(project); err != nil {
+		return "", err
+	}
+	b, err := proto.Marshal(content)
+	if err != nil {
+		return "", errors.Annotate(err, "marhsalling chunk").Err()
+	}
+	objID, err := generateObjectID()
+	if err != nil {
+		return "", err
+	}
+
+	name := FileName(project, objID)
+	doesNotExist := storage.Conditions{
+		DoesNotExist: true,
+	}
+	// Only create the file if it does not exist. The risk of collision if
+	// ID generation is working correctly is extremely remote so this mostly
+	// defensive coding and a failsafe against bad randomness in ID generation.
+	obj := c.client.Bucket(c.bucket).Object(name).If(doesNotExist)
+	w := obj.NewWriter(ctx)
+	defer func() {
+		if err := w.Close(); err != nil && retErr == nil {
+			retErr = errors.Annotate(err, "closing object writer").Err()
+		}
+	}()
+
+	// As the file is small (<8MB), set ChunkSize to object size to avoid
+	// excessive memory usage, as per the documentation. Otherwise use
+	// the default ChunkSize.
+	if len(b) < 8*1024*1024 {
+		w.ChunkSize = len(b)
+	}
+	w.ContentType = "application/x-protobuf"
+	_, err = w.Write(b)
+	if err != nil {
+		return "", errors.Annotate(err, "writing object %q", name).Err()
+	}
+	return objID, nil
+}
+
+// Get retrieves the chunk with the specified object ID and returns it.
+func (c *Client) Get(ctx context.Context, project, objectID string) (chunk *cpb.Chunk, retErr error) {
+	if err := validateProject(project); err != nil {
+		return nil, err
+	}
+	if err := validateObjectID(objectID); err != nil {
+		return nil, err
+	}
+	name := FileName(project, objectID)
+	obj := c.client.Bucket(c.bucket).Object(name)
+	r, err := obj.NewReader(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "creating reader %q", name).Err()
+	}
+	defer func() {
+		if err := r.Close(); err != nil && retErr == nil {
+			retErr = errors.Annotate(err, "closing object reader").Err()
+		}
+	}()
+
+	// Allocate a buffer of the correct size and use io.ReadFull instead of
+	// io.ReadAll to avoid needlessly reallocating slices.
+	b := make([]byte, r.Attrs.Size)
+	if _, err := io.ReadFull(r, b); err != nil {
+		return nil, errors.Annotate(err, "read object %q", name).Err()
+	}
+	content := &cpb.Chunk{}
+	if err := proto.Unmarshal(b, content); err != nil {
+		return nil, errors.Annotate(err, "unmarshal chunk").Err()
+	}
+	return content, nil
+}
+
+func validateProject(project string) error {
+	if !config.ProjectRe.MatchString(project) {
+		return fmt.Errorf("project %q is not a valid", project)
+	}
+	return nil
+}
+
+func validateObjectID(id string) error {
+	if !objectRe.MatchString(id) {
+		return fmt.Errorf("object ID %q is not a valid", id)
+	}
+	return nil
+}
+
+// generateObjectID returns a random 128-bit object ID, encoded as
+// 32 lowercase hexadecimal characters.
+func generateObjectID() (string, error) {
+	randomBytes := make([]byte, 16)
+	_, err := rand.Read(randomBytes)
+	if err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(randomBytes), nil
+}
+
+// FileName returns the file path in GCS for the object with the
+// given project and objectID. Exposed for testing only.
+func FileName(project, objectID string) string {
+	return fmt.Sprintf("/projects/%s/chunks/%s.binarypb", project, objectID)
+}
diff --git a/analysis/internal/clustering/chunkstore/fake.go b/analysis/internal/clustering/chunkstore/fake.go
new file mode 100644
index 0000000..ba30b9e
--- /dev/null
+++ b/analysis/internal/clustering/chunkstore/fake.go
@@ -0,0 +1,88 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package chunkstore
+
+import (
+	"context"
+	"fmt"
+
+	"go.chromium.org/luci/common/errors"
+
+	"google.golang.org/protobuf/proto"
+
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+)
+
+// FakeClient provides a fake implementation of a chunk store, for testing.
+// Chunks are stored in-memory.
+type FakeClient struct {
+	// Contents are the chunk stored in the store, by their file name.
+	// File names can be obtained using the FileName method.
+	Contents map[string]*cpb.Chunk
+
+	// A callback function to be called during Get(...). This allows
+	// the test to change the environment during the processing of
+	// a particular chunk.
+	GetCallack func(objectID string)
+}
+
+// NewFakeClient initialises a new FakeClient.
+func NewFakeClient() *FakeClient {
+	return &FakeClient{
+		Contents: make(map[string]*cpb.Chunk),
+	}
+}
+
+// Put saves the given chunk to storage. If successful, it returns
+// the randomly-assigned ID of the created object.
+func (fc *FakeClient) Put(ctx context.Context, project string, content *cpb.Chunk) (string, error) {
+	if err := validateProject(project); err != nil {
+		return "", err
+	}
+	_, err := proto.Marshal(content)
+	if err != nil {
+		return "", errors.Annotate(err, "marhsalling chunk").Err()
+	}
+	objID, err := generateObjectID()
+	if err != nil {
+		return "", err
+	}
+	name := FileName(project, objID)
+	if _, ok := fc.Contents[name]; ok {
+		// Indicates a test with poorly seeded randomness.
+		return "", errors.New("file already exists")
+	}
+	fc.Contents[name] = proto.Clone(content).(*cpb.Chunk)
+	return objID, nil
+}
+
+// Get retrieves the chunk with the specified object ID and returns it.
+func (fc *FakeClient) Get(ctx context.Context, project, objectID string) (*cpb.Chunk, error) {
+	if err := validateProject(project); err != nil {
+		return nil, err
+	}
+	if err := validateObjectID(objectID); err != nil {
+		return nil, err
+	}
+	name := FileName(project, objectID)
+	content, ok := fc.Contents[name]
+	if !ok {
+		return nil, fmt.Errorf("blob does not exist: %q", name)
+	}
+	if fc.GetCallack != nil {
+		fc.GetCallack(objectID)
+	}
+	return proto.Clone(content).(*cpb.Chunk), nil
+}
diff --git a/analysis/internal/clustering/clusterid.go b/analysis/internal/clustering/clusterid.go
new file mode 100644
index 0000000..cf98886
--- /dev/null
+++ b/analysis/internal/clustering/clusterid.go
@@ -0,0 +1,163 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import (
+	"fmt"
+	"strings"
+
+	"go.chromium.org/luci/common/errors"
+)
+
+// MaxClusterIDBytes is the maximum number of bytes the algorithm-determined
+// cluster ID may occupy. This is the raw number of bytes; if the ID is hex-
+// encoded (e.g. for use in a BigQuery table), its length in characters may
+// be double this number.
+const MaxClusterIDBytes = 16
+
+// RulesAlgorithmPrefix is the algorithm name prefix used by all versions
+// of the rules-based clustering algorithm.
+const RulesAlgorithmPrefix = "rules-"
+
+// TestNameAlgorithmPrefix is the algorithm name prefix used by all versions
+// of the test name clustering algorithm.
+const TestNameAlgorithmPrefix = "testname-"
+
+// FailureReasonAlgorithmPrefix is the algorithm name prefix used by all versions
+// of the failure reason clustering algorithm.
+const FailureReasonAlgorithmPrefix = "reason-"
+
+// ClusterID represents the identity of a cluster. The LUCI Project is
+// omitted as it is assumed to be implicit from the context.
+type ClusterID struct {
+	// Algorithm is the name of the clustering algorithm that identified
+	// the cluster.
+	Algorithm string `json:"algorithm"`
+	// ID is the cluster identifier returned by the algorithm. The underlying
+	// identifier is at most 16 bytes, but is represented here as a hexadecimal
+	// string of up to 32 lowercase hexadecimal characters.
+	ID string `json:"id"`
+}
+
+// Key returns a value that can be used to uniquely identify the Cluster.
+// This is designed for cases where it is desirable for cluster IDs
+// to be used as keys in a map.
+func (c ClusterID) Key() string {
+	return fmt.Sprintf("%s:%s", c.Algorithm, c.ID)
+}
+
+// String returns a string-representation of the cluster, for debugging.
+func (c ClusterID) String() string {
+	return c.Key()
+}
+
+// Validate validates the algorithm and ID parts
+// of the cluster ID are valid.
+func (c ClusterID) Validate() error {
+	if !AlgorithmRe.MatchString(c.Algorithm) {
+		return errors.New("algorithm not valid")
+	}
+	if err := c.ValidateIDPart(); err != nil {
+		return err
+	}
+	return nil
+}
+
+// ValidateIDPart validates that the ID part of the cluster ID is valid.
+func (c ClusterID) ValidateIDPart() error {
+	valid := true
+	for _, r := range c.ID {
+		// ID must be always be stored in lowercase, so that string equality can
+		// be used to determine if IDs are the same.
+		if !(('0' <= r && r <= '9') || ('a' <= r && r <= 'f')) {
+			valid = false
+		}
+	}
+	if !valid || (len(c.ID)%2 != 0) {
+		return errors.New("ID is not valid lowercase hexadecimal bytes")
+	}
+	bytes := len(c.ID) / 2
+	if bytes > MaxClusterIDBytes {
+		return fmt.Errorf("ID is too long (got %v bytes, want at most %v bytes)", bytes, MaxClusterIDBytes)
+	}
+	if bytes == 0 {
+		return errors.New("ID is empty")
+	}
+	return nil
+}
+
+// IsEmpty returns whether the cluster ID is equal to its
+// zero value.
+func (c ClusterID) IsEmpty() bool {
+	return c.Algorithm == "" && c.ID == ""
+}
+
+// IsBugCluster returns whether this cluster is backed by a failure
+// association rule, and produced by a version of the failure association
+// rule based clustering algorithm.
+func (c ClusterID) IsBugCluster() bool {
+	return strings.HasPrefix(c.Algorithm, RulesAlgorithmPrefix)
+}
+
+// IsTestNameCluster returns whether this cluster was made by a version
+// of the test name clustering algorithm.
+func (c ClusterID) IsTestNameCluster() bool {
+	return strings.HasPrefix(c.Algorithm, TestNameAlgorithmPrefix)
+}
+
+// IsFailureReasonCluster returns whether this cluster was made by a version
+// of the failure reason clustering algorithm.
+func (c ClusterID) IsFailureReasonCluster() bool {
+	return strings.HasPrefix(c.Algorithm, FailureReasonAlgorithmPrefix)
+
+}
+
+// SortClusters sorts the given clusters in ascending algorithm and then ID
+// order.
+func SortClusters(cs []ClusterID) {
+	// There are almost always a tiny number of clusters per test result,
+	// so a bubble-sort is surpringly faster than the built-in quicksort
+	// which has to make memory allocations.
+	for {
+		done := true
+		for i := 0; i < len(cs)-1; i++ {
+			if isClusterLess(cs[i+1], cs[i]) {
+				cs[i+1], cs[i] = cs[i], cs[i+1]
+				done = false
+			}
+		}
+		if done {
+			break
+		}
+	}
+}
+
+// ClustersAreSortedNoDuplicates verifies that clusters are in sorted order
+// and there are no duplicate clusters.
+func ClustersAreSortedNoDuplicates(cs []ClusterID) bool {
+	for i := 0; i < len(cs)-1; i++ {
+		if !isClusterLess(cs[i], cs[i+1]) {
+			return false
+		}
+	}
+	return true
+}
+
+func isClusterLess(a ClusterID, b ClusterID) bool {
+	if a.Algorithm == b.Algorithm {
+		return a.ID < b.ID
+	}
+	return a.Algorithm < b.Algorithm
+}
diff --git a/analysis/internal/clustering/clusterid_test.go b/analysis/internal/clustering/clusterid_test.go
new file mode 100644
index 0000000..352ae37
--- /dev/null
+++ b/analysis/internal/clustering/clusterid_test.go
@@ -0,0 +1,62 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import (
+	"encoding/hex"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestValidate(t *testing.T) {
+	Convey(`Validate`, t, func() {
+		id := ClusterID{
+			Algorithm: "blah-v2",
+			ID:        hex.EncodeToString([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}),
+		}
+		Convey(`Algorithm missing`, func() {
+			id.Algorithm = ""
+			err := id.Validate()
+			So(err, ShouldErrLike, `algorithm not valid`)
+		})
+		Convey("Algorithm invalid", func() {
+			id.Algorithm = "!!!"
+			err := id.Validate()
+			So(err, ShouldErrLike, `algorithm not valid`)
+		})
+		Convey("ID missing", func() {
+			id.ID = ""
+			err := id.Validate()
+			So(err, ShouldErrLike, `ID is empty`)
+		})
+		Convey("ID invalid", func() {
+			id.ID = "!!!"
+			err := id.Validate()
+			So(err, ShouldErrLike, `ID is not valid lowercase hexadecimal bytes`)
+		})
+		Convey("ID not lowercase", func() {
+			id.ID = "AA"
+			err := id.Validate()
+			So(err, ShouldErrLike, `ID is not valid lowercase hexadecimal bytes`)
+		})
+		Convey("ID too long", func() {
+			id.ID = hex.EncodeToString([]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17})
+			err := id.Validate()
+			So(err, ShouldErrLike, `ID is too long (got 17 bytes, want at most 16 bytes)`)
+		})
+	})
+}
diff --git a/analysis/internal/clustering/clusterresults.go b/analysis/internal/clustering/clusterresults.go
new file mode 100644
index 0000000..06ce52d
--- /dev/null
+++ b/analysis/internal/clustering/clusterresults.go
@@ -0,0 +1,100 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import (
+	"time"
+)
+
+// ClusterResults represents the results of clustering a list of
+// test failures.
+type ClusterResults struct {
+	// AlgorithmsVersion is the version of clustering algorithms used to
+	// cluster test results in this chunk. (This is a version over the
+	// set of algorithms, distinct from the version of a single algorithm,
+	// e.g.: v1 -> {reason-v1}, v2 -> {reason-v1, testname-v1},
+	// v3 -> {reason-v2, testname-v1}.)
+	AlgorithmsVersion int64
+	// ConfigVersion is the version of Weetbix project configuration
+	// used to cluster the test results. Clustering algorithms can rely
+	// on the configuration to alter their behaviour, so changes to
+	// the configuration should trigger re-clustering of test results.
+	ConfigVersion time.Time
+	// RulesVersion is the version of failure association rules used
+	// to cluster test results.  This is most recent PredicateLastUpdated
+	// time in the snapshot of failure association rules used to cluster
+	// the test results.
+	RulesVersion time.Time
+	// Algorithms is the set of algorithms that were used to cluster
+	// the test results. Each entry is an algorithm name.
+	// When stored alongside the clustered test results, this allows only
+	// the new algorithms to be run when re-clustering (for efficiency).
+	Algorithms map[string]struct{}
+	// Clusters records the clusters each test result is in;
+	// one slice of ClusterIDs for each test result. For each test result,
+	// clusters must be in sorted order, with no duplicates.
+	Clusters [][]ClusterID
+}
+
+// AlgorithmsAndClustersEqual returns whether the algorithms and clusters of
+// two cluster results are equivalent.
+func AlgorithmsAndClustersEqual(a *ClusterResults, b *ClusterResults) bool {
+	if !setsEqual(a.Algorithms, b.Algorithms) {
+		return false
+	}
+	if len(a.Clusters) != len(b.Clusters) {
+		return false
+	}
+	for i, aClusters := range a.Clusters {
+		bClusters := b.Clusters[i]
+		if !ClustersEqual(aClusters, bClusters) {
+			return false
+		}
+	}
+	return true
+}
+
+// ClustersEqual returns whether the clusters in `as` are element-wise
+// equal to those in `bs`.
+// To test set-wise cluster equality, this method is called with
+// clusters in sorted order, and no duplicates.
+func ClustersEqual(as []ClusterID, bs []ClusterID) bool {
+	if len(as) != len(bs) {
+		return false
+	}
+	for i, a := range as {
+		b := bs[i]
+		if a.Algorithm != b.Algorithm {
+			return false
+		}
+		if a.ID != b.ID {
+			return false
+		}
+	}
+	return true
+}
+
+// setsEqual returns whether two sets are equal.
+func setsEqual(a map[string]struct{}, b map[string]struct{}) bool {
+	if len(a) != len(b) {
+		return false
+	}
+	for key := range a {
+		if _, ok := b[key]; !ok {
+			return false
+		}
+	}
+	return true
+}
diff --git a/analysis/internal/clustering/clustersummary.go b/analysis/internal/clustering/clustersummary.go
new file mode 100644
index 0000000..81a38bf
--- /dev/null
+++ b/analysis/internal/clustering/clustersummary.go
@@ -0,0 +1,26 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+// ClusterSummary captures information about a cluster.
+// This is a subset of the information captured by Weetbix for failures.
+type ClusterSummary struct {
+	// Example is an example failure contained within the cluster.
+	Example Failure
+
+	// TopTests is a list of up to 5 most commonly occurring tests
+	// included in the cluster.
+	TopTests []string
+}
diff --git a/analysis/internal/clustering/constants.go b/analysis/internal/clustering/constants.go
new file mode 100644
index 0000000..489ce4e
--- /dev/null
+++ b/analysis/internal/clustering/constants.go
@@ -0,0 +1,30 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import "regexp"
+
+// ChunkRe matches validly formed chunk IDs.
+var ChunkRe = regexp.MustCompile(`^[0-9a-f]{1,32}$`)
+
+// AlgorithmRePattern is the regular expression pattern matching
+// validly formed clustering algorithm names.
+// The overarching requirement is [0-9a-z\-]{1,32}, which we
+// sudivide into an algorithm name of up to 26 characters
+// and an algorithm version number.
+const AlgorithmRePattern = `[0-9a-z\-]{1,26}-v[1-9][0-9]{0,3}`
+
+// AlgorithmRe matches validly formed clustering algorithm names.
+var AlgorithmRe = regexp.MustCompile(`^` + AlgorithmRePattern + `$`)
diff --git a/analysis/internal/clustering/description.go b/analysis/internal/clustering/description.go
new file mode 100644
index 0000000..24bc5df
--- /dev/null
+++ b/analysis/internal/clustering/description.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+// ClusterDescription captures the description of a cluster, for
+// use in bug filing.
+type ClusterDescription struct {
+	// Title is a short, one-line description of the cluster, for use
+	// in the bug title.
+	Title string
+	// Description is a human-readable description of the cluster.
+	Description string
+}
diff --git a/analysis/internal/clustering/escaping.go b/analysis/internal/clustering/escaping.go
new file mode 100644
index 0000000..1923a80
--- /dev/null
+++ b/analysis/internal/clustering/escaping.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import "strconv"
+
+// EscapeToGraphical escapes the input so that it only contains graphic unicode characters.
+// Use on test names and failure reasons before presenting to any UI context.
+func EscapeToGraphical(value string) string {
+	quotedEscaped := strconv.QuoteToGraphic(value)
+	// Remove starting end ending double-quotes.
+	return quotedEscaped[1 : len(quotedEscaped)-1]
+}
diff --git a/analysis/internal/clustering/failure.go b/analysis/internal/clustering/failure.go
new file mode 100644
index 0000000..2debbc9
--- /dev/null
+++ b/analysis/internal/clustering/failure.go
@@ -0,0 +1,53 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import (
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+
+	"google.golang.org/protobuf/proto"
+)
+
+// Failure captures the minimal information required to cluster a failure.
+// This is a subset of the information captured by Weetbix for failures.
+type Failure struct {
+	// The name of the test that failed.
+	TestID string
+	// The failure reason explaining the reason why the test failed.
+	Reason *pb.FailureReason
+}
+
+// FailureFromProto extracts failure information relevant for clustering from
+// a Weetbix failure proto.
+func FailureFromProto(f *cpb.Failure) *Failure {
+	result := &Failure{
+		TestID: f.TestId,
+	}
+	if f.FailureReason != nil {
+		result.Reason = proto.Clone(f.FailureReason).(*pb.FailureReason)
+	}
+	return result
+}
+
+// FailuresFromProtos extracts failure information relevant for clustering
+// from a set of Weetbix failure protos.
+func FailuresFromProtos(protos []*cpb.Failure) []*Failure {
+	result := make([]*Failure, len(protos))
+	for i, p := range protos {
+		result[i] = FailureFromProto(p)
+	}
+	return result
+}
diff --git a/analysis/internal/clustering/ingestion/ingest.go b/analysis/internal/clustering/ingestion/ingest.go
new file mode 100644
index 0000000..966ea4c
--- /dev/null
+++ b/analysis/internal/clustering/ingestion/ingest.go
@@ -0,0 +1,237 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ingestion
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/common/errors"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/span"
+
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/clustering/reclustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// Options represents parameters to the ingestion.
+type Options struct {
+	// The task index identifying the unique partition of the invocation
+	// being ingested.
+	TaskIndex int64
+	// Project is the LUCI Project.
+	Project string
+	// PartitionTime is the start of the retention period of test results
+	// being ingested.
+	PartitionTime time.Time
+	// Realm is the LUCI Realm of the test results.
+	Realm string
+	// InvocationID is the identity of the invocation being ingested.
+	InvocationID string
+	// The presubmit run (if any).
+	PresubmitRun *PresubmitRun
+	// The result of the build that was ingested.
+	BuildStatus pb.BuildStatus
+	// Whether the build was critical to the presubmit run.
+	// Ignored if PresubmitRun is nil.
+	BuildCritical bool
+	// The unsubmitted changelists that were tested (if any).
+	// Changelists are sorted in ascending (host, change, patchset) order.
+	// Up to 10 changelists are captured.
+	Changelists []*pb.Changelist
+}
+
+type PresubmitRun struct {
+	// ID is the identity of the presubmit run (if any).
+	ID *pb.PresubmitRunId
+	// Owner is the the owner of the presubmit
+	// run (if any). This is the owner of the CL on which CQ+1/CQ+2 was
+	// clicked (even in case of presubmit run with multiple CLs).
+	Owner string
+	// The mode of the presubmit run.
+	// E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+	Mode pb.PresubmitRunMode
+	// The presubmit run's ending status.
+	Status pb.PresubmitRunStatus
+}
+
+// ChunkStore is the interface for the blob store archiving chunks of test
+// results for later re-clustering.
+type ChunkStore interface {
+	// Put saves the given chunk to storage. If successful, it returns
+	// the randomly-assigned ID of the created object.
+	Put(ctx context.Context, project string, content *cpb.Chunk) (string, error)
+}
+
+// ChunkSize is the number of test failures that are to appear in each chunk.
+const ChunkSize = 1000
+
+// Ingester handles the ingestion of test results for clustering.
+type Ingester struct {
+	chunkStore ChunkStore
+	analysis   reclustering.Analysis
+}
+
+// New initialises a new Ingester.
+func New(cs ChunkStore, a reclustering.Analysis) *Ingester {
+	return &Ingester{
+		chunkStore: cs,
+		analysis:   a,
+	}
+}
+
+// Ingestion handles the ingestion of a single invocation for clustering,
+// in a streaming fashion.
+type Ingestion struct {
+	// ingestor provides access to shared objects for doing the ingestion.
+	ingester *Ingester
+	// opts is the Ingestion options.
+	opts Options
+	// buffer is the set of failures which have been queued for ingestion but
+	// not yet written to chunks.
+	//buffer []*cpb.Failure
+	// chunkSeq is the number of the chunk failures written out.
+	chunkSeq int
+}
+
+// Ingest performs the ingestion of the specified test variants, with
+// the specified options.
+func (i *Ingester) Ingest(ctx context.Context, opts Options, tvs []*rdbpb.TestVariant) error {
+	buffer := make([]*cpb.Failure, 0, ChunkSize)
+
+	chunkSeq := 0
+	writeChunk := func() error {
+		if len(buffer) == 0 {
+			panic("logic error: attempt to write empty chunk")
+		}
+		if len(buffer) > ChunkSize {
+			panic("logic error: attempt to write oversize chunk")
+		}
+		// Copy failures buffer.
+		failures := make([]*cpb.Failure, len(buffer))
+		copy(failures, buffer)
+
+		// Reset buffer.
+		buffer = buffer[0:0]
+
+		for i, f := range failures {
+			f.ChunkIndex = int64(i + 1)
+		}
+		chunk := &cpb.Chunk{
+			Failures: failures,
+		}
+		err := i.writeChunk(ctx, opts, chunkSeq, chunk)
+		chunkSeq++
+		return err
+	}
+
+	for _, tv := range tvs {
+		failures := failuresFromTestVariant(opts, tv)
+		// Write out chunks as needed, keeping all failures of
+		// a test variant in one chunk, and the chunk size within
+		// ChunkSize.
+		if len(buffer)+len(failures) > ChunkSize {
+			if err := writeChunk(); err != nil {
+				return err
+			}
+		}
+		buffer = append(buffer, failures...)
+	}
+
+	// Write out the last chunk (if needed).
+	if len(buffer) > 0 {
+		if err := writeChunk(); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// writeChunk will, for the given chunk:
+// - Archive the failures to GCS.
+// - Cluster the failures.
+// - Write out the chunk clustering state.
+// - Perform analysis.
+func (i *Ingester) writeChunk(ctx context.Context, opts Options, chunkSeq int, chunk *cpb.Chunk) error {
+	// Derive a chunkID deterministically from the ingested root invocation
+	// ID, task index and chunk number. In case of retry this avoids ingesting
+	// the same data twice.
+	id := chunkID(opts.InvocationID, opts.TaskIndex, chunkSeq)
+
+	_, err := state.Read(span.Single(ctx), opts.Project, id)
+	if err == nil {
+		// Chunk was already ingested as part of an earlier ingestion attempt.
+		// Do not attempt to ingest again.
+		return nil
+	}
+	if err != state.NotFoundErr {
+		return err
+	}
+
+	// Upload the chunk. The objectID is randomly generated each time
+	// so the actual insertion of the chunk will be atomic with the
+	// ClusteringState row in Spanner.
+	objectID, err := i.chunkStore.Put(ctx, opts.Project, chunk)
+	if err != nil {
+		return err
+	}
+
+	clusterState := &state.Entry{
+		Project:       opts.Project,
+		ChunkID:       id,
+		PartitionTime: opts.PartitionTime,
+		ObjectID:      objectID,
+	}
+
+	ruleset, err := reclustering.Ruleset(ctx, opts.Project, rules.StartingEpoch)
+	if err != nil {
+		return errors.Annotate(err, "obtain ruleset").Err()
+	}
+
+	cfg, err := compiledcfg.Project(ctx, opts.Project, config.StartingEpoch)
+	if err != nil {
+		return errors.Annotate(err, "obtain config").Err()
+	}
+
+	update, err := reclustering.PrepareUpdate(ctx, ruleset, cfg, chunk, clusterState)
+	if err != nil {
+		return err
+	}
+
+	updates := reclustering.NewPendingUpdates(ctx)
+	updates.Add(update)
+	if err := updates.Apply(ctx, i.analysis); err != nil {
+		return err
+	}
+	return nil
+}
+
+// chunkID generates an identifier for the chunk deterministically.
+// The identifier will be 32 lowercase hexadecimal characters. Generated
+// identifiers will be approximately evenly distributed through
+// the keyspace.
+func chunkID(rootInvocationID string, taskIndex int64, chunkSeq int) string {
+	content := fmt.Sprintf("%q:%v:%v", rootInvocationID, taskIndex, chunkSeq)
+	sha256 := sha256.Sum256([]byte(content))
+	return hex.EncodeToString(sha256[:16])
+}
diff --git a/analysis/internal/clustering/ingestion/ingest_test.go b/analysis/internal/clustering/ingestion/ingest_test.go
new file mode 100644
index 0000000..eedb5fd
--- /dev/null
+++ b/analysis/internal/clustering/ingestion/ingest_test.go
@@ -0,0 +1,535 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ingestion
+
+import (
+	"encoding/hex"
+	"fmt"
+	"sort"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/caching"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/durationpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/analysis/clusteredfailures"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/chunkstore"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestIngest(t *testing.T) {
+	Convey(`With Ingestor`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx = caching.WithEmptyProcessCache(ctx) // For rules cache.
+		ctx = memory.Use(ctx)                    // For project config in datastore.
+
+		chunkStore := chunkstore.NewFakeClient()
+		clusteredFailures := clusteredfailures.NewFakeClient()
+		analysis := analysis.NewClusteringHandler(clusteredFailures)
+		ingestor := New(chunkStore, analysis)
+
+		opts := Options{
+			TaskIndex:     1,
+			Project:       "chromium",
+			PartitionTime: time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC),
+			Realm:         "chromium:ci",
+			InvocationID:  "build-123456790123456",
+			PresubmitRun: &PresubmitRun{
+				ID:     &pb.PresubmitRunId{System: "luci-cv", Id: "cq-run-123"},
+				Owner:  "automation",
+				Mode:   pb.PresubmitRunMode_FULL_RUN,
+				Status: pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED,
+			},
+			BuildStatus:   pb.BuildStatus_BUILD_STATUS_FAILURE,
+			BuildCritical: true,
+			Changelists: []*pb.Changelist{
+				{
+					Host:     "chromium-review.googlesource.com",
+					Change:   12345,
+					Patchset: 1,
+				},
+				{
+					Host:     "chromium-review.googlesource.com",
+					Change:   67890,
+					Patchset: 2,
+				},
+			},
+		}
+		testIngestion := func(input []*rdbpb.TestVariant, expectedCFs []*bqpb.ClusteredFailureRow) {
+			err := ingestor.Ingest(ctx, opts, input)
+			So(err, ShouldBeNil)
+
+			insertions := clusteredFailures.InsertionsByProject["chromium"]
+			So(len(insertions), ShouldEqual, len(expectedCFs))
+
+			// Sort both actuals and expectations by key so that we compare corresponding rows.
+			sortClusteredFailures(insertions)
+			sortClusteredFailures(expectedCFs)
+			for i, exp := range expectedCFs {
+				actual := insertions[i]
+				So(actual, ShouldNotBeNil)
+
+				// Chunk ID and index is assigned by ingestion.
+				copyExp := proto.Clone(exp).(*bqpb.ClusteredFailureRow)
+				So(actual.ChunkId, ShouldNotBeEmpty)
+				So(actual.ChunkIndex, ShouldBeGreaterThanOrEqualTo, 1)
+				copyExp.ChunkId = actual.ChunkId
+				copyExp.ChunkIndex = actual.ChunkIndex
+
+				// LastUpdated time is assigned by Spanner.
+				So(actual.LastUpdated, ShouldNotBeZeroValue)
+				copyExp.LastUpdated = actual.LastUpdated
+
+				So(actual, ShouldResembleProto, copyExp)
+			}
+		}
+
+		// This rule should match failures used in this test.
+		rule := rules.NewRule(100).WithProject(opts.Project).WithRuleDefinition(`reason LIKE "Failure reason%"`).Build()
+		err := rules.SetRulesForTesting(ctx, []*rules.FailureAssociationRule{
+			rule,
+		})
+		So(err, ShouldBeNil)
+
+		// Setup clustering configuration
+		projectCfg := &configpb.ProjectConfig{
+			Clustering:  algorithms.TestClusteringConfig(),
+			LastUpdated: timestamppb.New(time.Date(2020, time.January, 5, 0, 0, 0, 1, time.UTC)),
+		}
+		projectCfgs := map[string]*configpb.ProjectConfig{
+			"chromium": projectCfg,
+		}
+		So(config.SetTestProjectConfig(ctx, projectCfgs), ShouldBeNil)
+
+		cfg, err := compiledcfg.NewConfig(projectCfg)
+		So(err, ShouldBeNil)
+
+		Convey(`Ingest one failure`, func() {
+			const uniqifier = 1
+			const testRunCount = 1
+			const resultsPerTestRun = 1
+			tv := newTestVariant(uniqifier, testRunCount, resultsPerTestRun)
+			tvs := []*rdbpb.TestVariant{tv}
+
+			// Expect the test result to be clustered by both reason and test name.
+			const testRunNum = 0
+			const resultNum = 0
+			regexpCF := expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum)
+			setRegexpClustered(cfg, regexpCF)
+			testnameCF := expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum)
+			setTestNameClustered(cfg, testnameCF)
+			ruleCF := expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum)
+			setRuleClustered(ruleCF, rule)
+			expectedCFs := []*bqpb.ClusteredFailureRow{regexpCF, testnameCF, ruleCF}
+
+			Convey(`Unexpected failure`, func() {
+				tv.Results[0].Result.Status = rdbpb.TestStatus_FAIL
+				tv.Results[0].Result.Expected = false
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+			Convey(`Expected failure`, func() {
+				tv.Results[0].Result.Status = rdbpb.TestStatus_FAIL
+				tv.Results[0].Result.Expected = true
+
+				// Expect no test results ingested for an expected
+				// failure.
+				expectedCFs = nil
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 0)
+			})
+			Convey(`Unexpected pass`, func() {
+				tv.Results[0].Result.Status = rdbpb.TestStatus_PASS
+				tv.Results[0].Result.Expected = false
+
+				// Expect no test results ingested for a passed test
+				// (even if unexpected).
+				expectedCFs = nil
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 0)
+			})
+			Convey(`Unexpected skip`, func() {
+				tv.Results[0].Result.Status = rdbpb.TestStatus_SKIP
+				tv.Results[0].Result.Expected = false
+
+				// Expect no test results ingested for a skipped test
+				// (even if unexpected).
+				expectedCFs = nil
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 0)
+			})
+			Convey(`Failure with no tags`, func() {
+				// Tests are allowed to have no tags.
+				tv.Results[0].Result.Tags = nil
+
+				for _, cf := range expectedCFs {
+					cf.Tags = nil
+					cf.BugTrackingComponent = nil
+				}
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+			Convey(`Failure without variant`, func() {
+				// Tests are allowed to have no variant.
+				tv.Variant = nil
+				tv.Results[0].Result.Variant = nil
+
+				for _, cf := range expectedCFs {
+					cf.Variant = nil
+				}
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+			Convey(`Failure without failure reason`, func() {
+				// Failures may not have a failure reason.
+				tv.Results[0].Result.FailureReason = nil
+				testnameCF.FailureReason = nil
+
+				// As the test result does not match any rules, the
+				// test result is included in the suggested cluster
+				// with high priority.
+				testnameCF.IsIncludedWithHighPriority = true
+				expectedCFs = []*bqpb.ClusteredFailureRow{testnameCF}
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+			Convey(`Failure without presubmit run`, func() {
+				opts.PresubmitRun = nil
+				for _, cf := range expectedCFs {
+					cf.PresubmitRunId = nil
+					cf.PresubmitRunMode = ""
+					cf.PresubmitRunOwner = ""
+					cf.PresubmitRunStatus = ""
+					cf.BuildCritical = false
+				}
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+			Convey(`Failure with multiple exoneration`, func() {
+				tv.Exonerations = []*rdbpb.TestExoneration{
+					{
+						Name:            fmt.Sprintf("invocations/testrun-mytestrun/tests/test-name-%v/exonerations/exon-1", uniqifier),
+						TestId:          tv.TestId,
+						Variant:         proto.Clone(tv.Variant).(*rdbpb.Variant),
+						VariantHash:     "hash",
+						ExonerationId:   "exon-1",
+						ExplanationHtml: "<p>Some description</p>",
+						Reason:          rdbpb.ExonerationReason_OCCURS_ON_MAINLINE,
+					},
+					{
+						Name:            fmt.Sprintf("invocations/testrun-mytestrun/tests/test-name-%v/exonerations/exon-1", uniqifier),
+						TestId:          tv.TestId,
+						Variant:         proto.Clone(tv.Variant).(*rdbpb.Variant),
+						VariantHash:     "hash",
+						ExonerationId:   "exon-1",
+						ExplanationHtml: "<p>Some description</p>",
+						Reason:          rdbpb.ExonerationReason_OCCURS_ON_OTHER_CLS,
+					},
+				}
+
+				for _, cf := range expectedCFs {
+					cf.Exonerations = []*bqpb.ClusteredFailureRow_TestExoneration{
+						{
+							Reason: pb.ExonerationReason_OCCURS_ON_MAINLINE,
+						}, {
+							Reason: pb.ExonerationReason_OCCURS_ON_OTHER_CLS,
+						},
+					}
+				}
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+			Convey(`Failure with only suggested clusters`, func() {
+				reason := &pb.FailureReason{
+					PrimaryErrorMessage: "Should not match rule",
+				}
+				tv.Results[0].Result.FailureReason = &rdbpb.FailureReason{
+					PrimaryErrorMessage: "Should not match rule",
+				}
+				testnameCF.FailureReason = reason
+				regexpCF.FailureReason = reason
+
+				// Recompute the cluster ID to reflect the different
+				// failure reason.
+				setRegexpClustered(cfg, regexpCF)
+
+				// As the test result does not match any rules, the
+				// test result should be included in the suggested clusters
+				// with high priority.
+				testnameCF.IsIncludedWithHighPriority = true
+				regexpCF.IsIncludedWithHighPriority = true
+				expectedCFs = []*bqpb.ClusteredFailureRow{testnameCF, regexpCF}
+
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+		})
+		Convey(`Ingest multiple failures`, func() {
+			const uniqifier = 1
+			const testRunsPerVariant = 2
+			const resultsPerTestRun = 2
+			tv := newTestVariant(uniqifier, testRunsPerVariant, resultsPerTestRun)
+			tvs := []*rdbpb.TestVariant{tv}
+
+			// Setup a scenario as follows:
+			// - A test was run four times in total, consisting of two test
+			//   runs with two tries each.
+			// - The test failed on all tries.
+			var expectedCFs []*bqpb.ClusteredFailureRow
+			var expectedCFsByTestRun [][]*bqpb.ClusteredFailureRow
+			for t := 0; t < testRunsPerVariant; t++ {
+				var testRunExp []*bqpb.ClusteredFailureRow
+				for j := 0; j < resultsPerTestRun; j++ {
+					regexpCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
+					setRegexpClustered(cfg, regexpCF)
+					testnameCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
+					setTestNameClustered(cfg, testnameCF)
+					ruleCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
+					setRuleClustered(ruleCF, rule)
+					testRunExp = append(testRunExp, regexpCF, testnameCF, ruleCF)
+				}
+				expectedCFsByTestRun = append(expectedCFsByTestRun, testRunExp)
+				expectedCFs = append(expectedCFs, testRunExp...)
+			}
+
+			// Expectation: all test results show both the test run and
+			// invocation blocked by failures.
+			for _, exp := range expectedCFs {
+				exp.IsIngestedInvocationBlocked = true
+				exp.IsTestRunBlocked = true
+			}
+
+			Convey(`Some test runs blocked and presubmit run not blocked`, func() {
+				// Let the last retry of the last test run pass.
+				tv.Results[testRunsPerVariant*resultsPerTestRun-1].Result.Status = rdbpb.TestStatus_PASS
+				// Drop the expected clustered failures for the last test result.
+				expectedCFs = expectedCFs[0 : (testRunsPerVariant*resultsPerTestRun-1)*3]
+
+				// First test run should be blocked.
+				for _, exp := range expectedCFsByTestRun[0] {
+					exp.IsIngestedInvocationBlocked = false
+					exp.IsTestRunBlocked = true
+				}
+				// Last test run should not be blocked.
+				for _, exp := range expectedCFsByTestRun[testRunsPerVariant-1] {
+					exp.IsIngestedInvocationBlocked = false
+					exp.IsTestRunBlocked = false
+				}
+				testIngestion(tvs, expectedCFs)
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+			})
+		})
+		Convey(`Ingest many failures`, func() {
+			var tvs []*rdbpb.TestVariant
+			var expectedCFs []*bqpb.ClusteredFailureRow
+
+			const variantCount = 20
+			const testRunsPerVariant = 10
+			const resultsPerTestRun = 10
+			for uniqifier := 0; uniqifier < variantCount; uniqifier++ {
+				tv := newTestVariant(uniqifier, testRunsPerVariant, resultsPerTestRun)
+				tvs = append(tvs, tv)
+				for t := 0; t < testRunsPerVariant; t++ {
+					for j := 0; j < resultsPerTestRun; j++ {
+						regexpCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
+						setRegexpClustered(cfg, regexpCF)
+						testnameCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
+						setTestNameClustered(cfg, testnameCF)
+						ruleCF := expectedClusteredFailure(uniqifier, testRunsPerVariant, t, resultsPerTestRun, j)
+						setRuleClustered(ruleCF, rule)
+						expectedCFs = append(expectedCFs, regexpCF, testnameCF, ruleCF)
+					}
+				}
+			}
+			// Verify more than one chunk is ingested.
+			testIngestion(tvs, expectedCFs)
+			So(len(chunkStore.Contents), ShouldBeGreaterThan, 1)
+		})
+	})
+}
+
+func setTestNameClustered(cfg *compiledcfg.ProjectConfig, e *bqpb.ClusteredFailureRow) {
+	e.ClusterAlgorithm = testname.AlgorithmName
+	e.ClusterId = hex.EncodeToString((&testname.Algorithm{}).Cluster(cfg, &clustering.Failure{
+		TestID: e.TestId,
+	}))
+}
+
+func setRegexpClustered(cfg *compiledcfg.ProjectConfig, e *bqpb.ClusteredFailureRow) {
+	e.ClusterAlgorithm = failurereason.AlgorithmName
+	e.ClusterId = hex.EncodeToString((&failurereason.Algorithm{}).Cluster(cfg, &clustering.Failure{
+		Reason: &pb.FailureReason{PrimaryErrorMessage: e.FailureReason.PrimaryErrorMessage},
+	}))
+}
+
+func setRuleClustered(e *bqpb.ClusteredFailureRow, rule *rules.FailureAssociationRule) {
+	e.ClusterAlgorithm = rulesalgorithm.AlgorithmName
+	e.ClusterId = rule.RuleID
+	e.IsIncludedWithHighPriority = true
+}
+
+func sortClusteredFailures(cfs []*bqpb.ClusteredFailureRow) {
+	sort.Slice(cfs, func(i, j int) bool {
+		return clusteredFailureKey(cfs[i]) < clusteredFailureKey(cfs[j])
+	})
+}
+
+func clusteredFailureKey(cf *bqpb.ClusteredFailureRow) string {
+	return fmt.Sprintf("%q/%q/%q/%q", cf.ClusterAlgorithm, cf.ClusterId, cf.TestResultSystem, cf.TestResultId)
+}
+
+func newTestVariant(uniqifier int, testRunCount int, resultsPerTestRun int) *rdbpb.TestVariant {
+	testID := fmt.Sprintf("ninja://test_name/%v", uniqifier)
+	variant := &rdbpb.Variant{
+		Def: map[string]string{
+			"k1": "v1",
+		},
+	}
+	tv := &rdbpb.TestVariant{
+		TestId:       testID,
+		Variant:      variant,
+		VariantHash:  "hash",
+		Status:       rdbpb.TestVariantStatus_UNEXPECTED,
+		Exonerations: nil,
+		TestMetadata: &rdbpb.TestMetadata{},
+	}
+	for i := 0; i < testRunCount; i++ {
+		for j := 0; j < resultsPerTestRun; j++ {
+			tr := newTestResult(uniqifier, i, j)
+			// Test ID, Variant, VariantHash are not populated on the test
+			// results of a Test Variant as it is present on the parent record.
+			tr.TestId = ""
+			tr.Variant = nil
+			tr.VariantHash = ""
+			tv.Results = append(tv.Results, &rdbpb.TestResultBundle{Result: tr})
+		}
+	}
+	return tv
+}
+
+func newTestResult(uniqifier, testRunNum, resultNum int) *rdbpb.TestResult {
+	resultID := fmt.Sprintf("result-%v-%v", testRunNum, resultNum)
+	return &rdbpb.TestResult{
+		Name:        fmt.Sprintf("invocations/testrun-%v/tests/test-name-%v/results/%s", testRunNum, uniqifier, resultID),
+		ResultId:    resultID,
+		Expected:    false,
+		Status:      rdbpb.TestStatus_CRASH,
+		SummaryHtml: "<p>Some SummaryHTML</p>",
+		StartTime:   timestamppb.New(time.Date(2022, time.February, 12, 0, 0, 0, 0, time.UTC)),
+		Duration:    durationpb.New(time.Second * 10),
+		Tags: []*rdbpb.StringPair{
+			{
+				Key:   "monorail_component",
+				Value: "Component>MyComponent",
+			},
+		},
+		TestMetadata: &rdbpb.TestMetadata{},
+		FailureReason: &rdbpb.FailureReason{
+			PrimaryErrorMessage: "Failure reason.",
+		},
+	}
+}
+
+func expectedClusteredFailure(uniqifier, testRunCount, testRunNum, resultsPerTestRun, resultNum int) *bqpb.ClusteredFailureRow {
+	resultID := fmt.Sprintf("result-%v-%v", testRunNum, resultNum)
+	return &bqpb.ClusteredFailureRow{
+		ClusterAlgorithm: "", // Determined by clustering algorithm.
+		ClusterId:        "", // Determined by clustering algorithm.
+		TestResultSystem: "resultdb",
+		TestResultId:     fmt.Sprintf("invocations/testrun-%v/tests/test-name-%v/results/%s", testRunNum, uniqifier, resultID),
+		LastUpdated:      nil, // Only known at runtime, Spanner commit timestamp.
+
+		PartitionTime:              timestamppb.New(time.Date(2020, time.January, 1, 0, 0, 0, 0, time.UTC)),
+		IsIncluded:                 true,
+		IsIncludedWithHighPriority: false,
+
+		ChunkId:    "",
+		ChunkIndex: 0, // To be set by caller as needed.
+
+		Realm:  "chromium:ci",
+		TestId: fmt.Sprintf("ninja://test_name/%v", uniqifier),
+		Tags: []*pb.StringPair{
+			{
+				Key:   "monorail_component",
+				Value: "Component>MyComponent",
+			},
+		},
+		Variant: []*pb.StringPair{
+			{
+				Key:   "k1",
+				Value: "v1",
+			},
+		},
+		VariantHash:          "hash",
+		FailureReason:        &pb.FailureReason{PrimaryErrorMessage: "Failure reason."},
+		BugTrackingComponent: &pb.BugTrackingComponent{System: "monorail", Component: "Component>MyComponent"},
+		StartTime:            timestamppb.New(time.Date(2022, time.February, 12, 0, 0, 0, 0, time.UTC)),
+		Duration:             10.0,
+		Exonerations:         nil,
+
+		PresubmitRunId:     &pb.PresubmitRunId{System: "luci-cv", Id: "cq-run-123"},
+		PresubmitRunOwner:  "automation",
+		PresubmitRunMode:   "FULL_RUN", // pb.PresubmitRunMode_FULL_RUN
+		PresubmitRunStatus: "FAILED",   // pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED,
+		BuildStatus:        "FAILURE",  // pb.BuildStatus_BUILD_STATUS_FAILURE
+		BuildCritical:      true,
+		Changelists: []*pb.Changelist{
+			{
+				Host:     "chromium-review.googlesource.com",
+				Change:   12345,
+				Patchset: 1,
+			},
+			{
+				Host:     "chromium-review.googlesource.com",
+				Change:   67890,
+				Patchset: 2,
+			},
+		},
+		IngestedInvocationId:          "build-123456790123456",
+		IngestedInvocationResultIndex: int64(testRunNum*resultsPerTestRun + resultNum),
+		IngestedInvocationResultCount: int64(testRunCount * resultsPerTestRun),
+		IsIngestedInvocationBlocked:   true,
+		TestRunId:                     fmt.Sprintf("testrun-%v", testRunNum),
+		TestRunResultIndex:            int64(resultNum),
+		TestRunResultCount:            int64(resultsPerTestRun),
+		IsTestRunBlocked:              true,
+	}
+}
diff --git a/analysis/internal/clustering/ingestion/main_test.go b/analysis/internal/clustering/ingestion/main_test.go
new file mode 100644
index 0000000..299304e
--- /dev/null
+++ b/analysis/internal/clustering/ingestion/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ingestion
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/ingestion/resultdb.go b/analysis/internal/clustering/ingestion/resultdb.go
new file mode 100644
index 0000000..0a7bb76
--- /dev/null
+++ b/analysis/internal/clustering/ingestion/resultdb.go
@@ -0,0 +1,167 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ingestion
+
+import (
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/ingestion/resultdb"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func failuresFromTestVariant(opts Options, tv *rdbpb.TestVariant) []*cpb.Failure {
+	var failures []*cpb.Failure
+	if tv.Status == rdbpb.TestVariantStatus_EXPECTED {
+		// Short circuit: There will be nothing in the test variant to
+		// ingest, as everything is expected.
+		return nil
+	}
+
+	// Whether there were any (non-skip) passed or expected results.
+	var hasPass bool
+	for _, tr := range tv.Results {
+		if tr.Result.Status != rdbpb.TestStatus_SKIP &&
+			(tr.Result.Status == rdbpb.TestStatus_PASS ||
+				tr.Result.Expected) {
+			hasPass = true
+		}
+	}
+
+	// Group test results by run and sort in order of start time.
+	resultsByRun := resultdb.GroupAndOrderTestResults(tv.Results)
+
+	resultIndex := 0
+	for _, run := range resultsByRun {
+		// Whether there were any passed or expected results in the run.
+		var testRunHasPass bool
+		for _, tr := range run {
+			if tr.Result.Status != rdbpb.TestStatus_SKIP &&
+				(tr.Result.Status == rdbpb.TestStatus_PASS ||
+					tr.Result.Expected) {
+				testRunHasPass = true
+			}
+		}
+
+		for i, tr := range run {
+			if tr.Result.Expected || !isFailure(tr.Result.Status) {
+				// Only unexpected failures are ingested for clustering.
+				resultIndex++
+				continue
+			}
+
+			failure := failureFromResult(tv, tr.Result, opts)
+			failure.IngestedInvocationResultIndex = int64(resultIndex)
+			failure.IngestedInvocationResultCount = int64(len(tv.Results))
+			failure.IsIngestedInvocationBlocked = !hasPass
+			failure.TestRunResultIndex = int64(i)
+			failure.TestRunResultCount = int64(len(run))
+			failure.IsTestRunBlocked = !testRunHasPass
+			failures = append(failures, failure)
+
+			resultIndex++
+		}
+	}
+	return failures
+}
+
+func isFailure(s rdbpb.TestStatus) bool {
+	return (s == rdbpb.TestStatus_ABORT ||
+		s == rdbpb.TestStatus_CRASH ||
+		s == rdbpb.TestStatus_FAIL)
+}
+
+func failureFromResult(tv *rdbpb.TestVariant, tr *rdbpb.TestResult, opts Options) *cpb.Failure {
+	exonerations := make([]*cpb.TestExoneration, 0, len(tv.Exonerations))
+	for _, e := range tv.Exonerations {
+		exonerations = append(exonerations, exonerationFromResultDB(e))
+	}
+
+	var presubmitRun *cpb.PresubmitRun
+	var buildCritical *bool
+	if opts.PresubmitRun != nil {
+		presubmitRun = &cpb.PresubmitRun{
+			PresubmitRunId: opts.PresubmitRun.ID,
+			Owner:          opts.PresubmitRun.Owner,
+			Mode:           opts.PresubmitRun.Mode,
+			Status:         opts.PresubmitRun.Status,
+		}
+		buildCritical = &opts.BuildCritical
+	}
+
+	testRunID, err := resultdb.InvocationFromTestResultName(tr.Name)
+	if err != nil {
+		// Should never happen, as the result name from ResultDB
+		// should be valid.
+		panic(err)
+	}
+
+	result := &cpb.Failure{
+		TestResultId:                  pbutil.TestResultIDFromResultDB(tr.Name),
+		PartitionTime:                 timestamppb.New(opts.PartitionTime),
+		ChunkIndex:                    0, // To be populated by chunking.
+		Realm:                         opts.Realm,
+		TestId:                        tv.TestId,                              // Get from variant, as it is not populated on each result.
+		Variant:                       pbutil.VariantFromResultDB(tv.Variant), // Get from variant, as it is not populated on each result.
+		Tags:                          pbutil.StringPairFromResultDB(tr.Tags),
+		VariantHash:                   tv.VariantHash, // Get from variant, as it is not populated on each result.
+		FailureReason:                 pbutil.FailureReasonFromResultDB(tr.FailureReason),
+		BugTrackingComponent:          extractBugTrackingComponent(tr.Tags),
+		StartTime:                     tr.StartTime,
+		Duration:                      tr.Duration,
+		Exonerations:                  exonerations,
+		PresubmitRun:                  presubmitRun,
+		BuildStatus:                   opts.BuildStatus,
+		BuildCritical:                 buildCritical,
+		Changelists:                   opts.Changelists,
+		IngestedInvocationId:          opts.InvocationID,
+		IngestedInvocationResultIndex: -1,    // To be populated by caller.
+		IngestedInvocationResultCount: -1,    // To be populated by caller.
+		IsIngestedInvocationBlocked:   false, // To be populated by caller.
+		TestRunId:                     testRunID,
+		TestRunResultIndex:            -1,    // To be populated by caller.
+		TestRunResultCount:            -1,    // To be populated by caller.
+		IsTestRunBlocked:              false, // To be populated by caller.
+	}
+
+	// Copy the result to avoid the result aliasing any of the protos used as input.
+	return proto.Clone(result).(*cpb.Failure)
+}
+
+func exonerationFromResultDB(e *rdbpb.TestExoneration) *cpb.TestExoneration {
+	return &cpb.TestExoneration{
+		Reason: pbutil.ExonerationReasonFromResultDB(e.Reason),
+	}
+}
+
+func extractBugTrackingComponent(tags []*rdbpb.StringPair) *pb.BugTrackingComponent {
+	var value string
+	for _, tag := range tags {
+		if tag.Key == "monorail_component" {
+			value = tag.Value
+			break
+		}
+	}
+	if value != "" {
+		return &pb.BugTrackingComponent{
+			System:    "monorail",
+			Component: value,
+		}
+	}
+	return nil
+}
diff --git a/analysis/internal/clustering/proto/clusters.pb.go b/analysis/internal/clustering/proto/clusters.pb.go
new file mode 100644
index 0000000..0a6ad9b
--- /dev/null
+++ b/analysis/internal/clustering/proto/clusters.pb.go
@@ -0,0 +1,415 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/clustering/proto/clusters.proto
+
+package clusteringpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Represents the clusters a chunk of test results are included in.
+type ChunkClusters struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The types of clusters in this proto.
+	ClusterTypes []*ClusterType `protobuf:"bytes,1,rep,name=cluster_types,json=clusterTypes,proto3" json:"cluster_types,omitempty"`
+	// The identifiers of the clusters referenced in this proto.
+	ReferencedClusters []*ReferencedCluster `protobuf:"bytes,2,rep,name=referenced_clusters,json=referencedClusters,proto3" json:"referenced_clusters,omitempty"`
+	// The clusters of test results in the chunk. This is a list, so the first
+	// TestResultClusters message is for first test result in the chunk,
+	// the second message is for the second test result, and so on.
+	ResultClusters []*TestResultClusters `protobuf:"bytes,3,rep,name=result_clusters,json=resultClusters,proto3" json:"result_clusters,omitempty"`
+}
+
+func (x *ChunkClusters) Reset() {
+	*x = ChunkClusters{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ChunkClusters) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ChunkClusters) ProtoMessage() {}
+
+func (x *ChunkClusters) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ChunkClusters.ProtoReflect.Descriptor instead.
+func (*ChunkClusters) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ChunkClusters) GetClusterTypes() []*ClusterType {
+	if x != nil {
+		return x.ClusterTypes
+	}
+	return nil
+}
+
+func (x *ChunkClusters) GetReferencedClusters() []*ReferencedCluster {
+	if x != nil {
+		return x.ReferencedClusters
+	}
+	return nil
+}
+
+func (x *ChunkClusters) GetResultClusters() []*TestResultClusters {
+	if x != nil {
+		return x.ResultClusters
+	}
+	return nil
+}
+
+// Defines a type of cluster.
+type ClusterType struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The algorithm used to create the cluster, e.g. "reason-0.1" for reason-based
+	// clustering or "rule-0.1" for clusters based on failure association rules.
+	// If specific algorithm versions are deprecated, this will allow us to target
+	// cluster references for deletion.
+	Algorithm string `protobuf:"bytes,1,opt,name=algorithm,proto3" json:"algorithm,omitempty"`
+}
+
+func (x *ClusterType) Reset() {
+	*x = ClusterType{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterType) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterType) ProtoMessage() {}
+
+func (x *ClusterType) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterType.ProtoReflect.Descriptor instead.
+func (*ClusterType) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ClusterType) GetAlgorithm() string {
+	if x != nil {
+		return x.Algorithm
+	}
+	return ""
+}
+
+// Represents a reference to a cluster.
+type ReferencedCluster struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The type of the referenced cluster, represented by an index
+	// into the cluster_types list of ChunkClusters.
+	TypeRef int64 `protobuf:"varint,1,opt,name=type_ref,json=typeRef,proto3" json:"type_ref,omitempty"`
+	// The identifier of the referenced cluster (up to 16 bytes).
+	ClusterId []byte `protobuf:"bytes,2,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
+}
+
+func (x *ReferencedCluster) Reset() {
+	*x = ReferencedCluster{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ReferencedCluster) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ReferencedCluster) ProtoMessage() {}
+
+func (x *ReferencedCluster) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ReferencedCluster.ProtoReflect.Descriptor instead.
+func (*ReferencedCluster) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *ReferencedCluster) GetTypeRef() int64 {
+	if x != nil {
+		return x.TypeRef
+	}
+	return 0
+}
+
+func (x *ReferencedCluster) GetClusterId() []byte {
+	if x != nil {
+		return x.ClusterId
+	}
+	return nil
+}
+
+// Represents the clusters a test result is included in.
+type TestResultClusters struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The clusters the test result is a member of. Clusters are identified by
+	// their index in the referenced_clusters list.
+	ClusterRefs []int64 `protobuf:"varint,1,rep,packed,name=cluster_refs,json=clusterRefs,proto3" json:"cluster_refs,omitempty"`
+}
+
+func (x *TestResultClusters) Reset() {
+	*x = TestResultClusters{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestResultClusters) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestResultClusters) ProtoMessage() {}
+
+func (x *TestResultClusters) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestResultClusters.ProtoReflect.Descriptor instead.
+func (*TestResultClusters) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *TestResultClusters) GetClusterRefs() []int64 {
+	if x != nil {
+		return x.ClusterRefs
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_internal_clustering_proto_clusters_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDesc = []byte{
+	0x0a, 0x40, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x12, 0x1b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65,
+	0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x22,
+	0x99, 0x02, 0x0a, 0x0d, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x74, 0x79, 0x70,
+	0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x79,
+	0x70, 0x65, 0x52, 0x0c, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x73,
+	0x12, 0x5f, 0x0a, 0x13, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x64, 0x5f, 0x63,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c,
+	0x2e, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2e, 0x52, 0x65, 0x66, 0x65,
+	0x72, 0x65, 0x6e, 0x63, 0x65, 0x64, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x12, 0x72,
+	0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x64, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x73, 0x12, 0x58, 0x0a, 0x0f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c,
+	0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x52, 0x0e, 0x72, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x22, 0x2b, 0x0a, 0x0b, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x54, 0x79, 0x70, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x61, 0x6c,
+	0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61,
+	0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x22, 0x4d, 0x0a, 0x11, 0x52, 0x65, 0x66, 0x65,
+	0x72, 0x65, 0x6e, 0x63, 0x65, 0x64, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x19, 0x0a,
+	0x08, 0x74, 0x79, 0x70, 0x65, 0x5f, 0x72, 0x65, 0x66, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52,
+	0x07, 0x74, 0x79, 0x70, 0x65, 0x52, 0x65, 0x66, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x63, 0x6c,
+	0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x22, 0x3b, 0x0a, 0x12, 0x54, 0x65, 0x73, 0x74, 0x52,
+	0x65, 0x73, 0x75, 0x6c, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a,
+	0x0c, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x72, 0x65, 0x66, 0x73, 0x18, 0x01, 0x20,
+	0x03, 0x28, 0x03, 0x42, 0x02, 0x10, 0x01, 0x52, 0x0b, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x52, 0x65, 0x66, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70,
+	0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x69, 0x6e, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65,
+	0x72, 0x69, 0x6e, 0x67, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescData = file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_goTypes = []interface{}{
+	(*ChunkClusters)(nil),      // 0: weetbix.internal.clustering.ChunkClusters
+	(*ClusterType)(nil),        // 1: weetbix.internal.clustering.ClusterType
+	(*ReferencedCluster)(nil),  // 2: weetbix.internal.clustering.ReferencedCluster
+	(*TestResultClusters)(nil), // 3: weetbix.internal.clustering.TestResultClusters
+}
+var file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_depIdxs = []int32{
+	1, // 0: weetbix.internal.clustering.ChunkClusters.cluster_types:type_name -> weetbix.internal.clustering.ClusterType
+	2, // 1: weetbix.internal.clustering.ChunkClusters.referenced_clusters:type_name -> weetbix.internal.clustering.ReferencedCluster
+	3, // 2: weetbix.internal.clustering.ChunkClusters.result_clusters:type_name -> weetbix.internal.clustering.TestResultClusters
+	3, // [3:3] is the sub-list for method output_type
+	3, // [3:3] is the sub-list for method input_type
+	3, // [3:3] is the sub-list for extension type_name
+	3, // [3:3] is the sub-list for extension extendee
+	0, // [0:3] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_init() }
+func file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_init() {
+	if File_infra_appengine_weetbix_internal_clustering_proto_clusters_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ChunkClusters); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterType); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ReferencedCluster); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestResultClusters); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_internal_clustering_proto_clusters_proto = out.File
+	file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_rawDesc = nil
+	file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_goTypes = nil
+	file_infra_appengine_weetbix_internal_clustering_proto_clusters_proto_depIdxs = nil
+}
diff --git a/analysis/internal/clustering/proto/clusters.proto b/analysis/internal/clustering/proto/clusters.proto
new file mode 100644
index 0000000..1c577db
--- /dev/null
+++ b/analysis/internal/clustering/proto/clusters.proto
@@ -0,0 +1,61 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.internal.clustering;
+
+option go_package = "go.chromium.org/luci/analysis/internal/clustering/proto;clusteringpb";
+
+// Represents the clusters a chunk of test results are included in.
+message ChunkClusters {
+  // The types of clusters in this proto.
+  repeated ClusterType cluster_types = 1;
+
+  // The identifiers of the clusters referenced in this proto.
+  repeated ReferencedCluster referenced_clusters = 2;
+
+  // The clusters of test results in the chunk. This is a list, so the first
+  // TestResultClusters message is for first test result in the chunk,
+  // the second message is for the second test result, and so on.
+  repeated TestResultClusters result_clusters = 3;
+}
+
+// Defines a type of cluster.
+message ClusterType {
+  // The algorithm used to create the cluster, e.g. "reason-0.1" for reason-based
+  // clustering or "rule-0.1" for clusters based on failure association rules.
+  // If specific algorithm versions are deprecated, this will allow us to target
+  // cluster references for deletion.
+  string algorithm = 1;
+
+  // Other information we may wish to store about the cluster, like priority, etc.
+}
+
+// Represents a reference to a cluster.
+message ReferencedCluster {
+  // The type of the referenced cluster, represented by an index
+  // into the cluster_types list of ChunkClusters.
+  int64 type_ref = 1;
+
+  // The identifier of the referenced cluster (up to 16 bytes).
+  bytes cluster_id = 2;
+}
+
+// Represents the clusters a test result is included in.
+message TestResultClusters {
+  // The clusters the test result is a member of. Clusters are identified by
+  // their index in the referenced_clusters list.
+  repeated int64 cluster_refs = 1 [ packed = true ];
+}
diff --git a/analysis/internal/clustering/proto/failure.pb.go b/analysis/internal/clustering/proto/failure.pb.go
new file mode 100644
index 0000000..78333fe
--- /dev/null
+++ b/analysis/internal/clustering/proto/failure.pb.go
@@ -0,0 +1,829 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/clustering/proto/failure.proto
+
+package clusteringpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	durationpb "google.golang.org/protobuf/types/known/durationpb"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Chunk is a set of unexpected test failures which are processed together
+// for efficiency.
+// Serialised and stored in GCS.
+type Chunk struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Failures []*Failure `protobuf:"bytes,1,rep,name=failures,proto3" json:"failures,omitempty"`
+}
+
+func (x *Chunk) Reset() {
+	*x = Chunk{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Chunk) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Chunk) ProtoMessage() {}
+
+func (x *Chunk) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Chunk.ProtoReflect.Descriptor instead.
+func (*Chunk) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Chunk) GetFailures() []*Failure {
+	if x != nil {
+		return x.Failures
+	}
+	return nil
+}
+
+// Weetbix internal representation of an unexpected test failure.
+// Next ID: 30.
+type Failure struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The identity of the test result, as defined by the source system.
+	TestResultId *v1.TestResultId `protobuf:"bytes,1,opt,name=test_result_id,json=testResultId,proto3" json:"test_result_id,omitempty"`
+	// Timestamp representing the start of the data retention period. This acts
+	// as the partitioning key in time/date-partitioned tables.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// The one-based index of this failure within the chunk. Assigned by
+	// Weetbix ingestion.
+	ChunkIndex int64 `protobuf:"varint,3,opt,name=chunk_index,json=chunkIndex,proto3" json:"chunk_index,omitempty"`
+	// Security realm of the test result.
+	// For test results from ResultDB, this must be set. The format is
+	// "{LUCI_PROJECT}:{REALM_SUFFIX}", for example "chromium:ci".
+	Realm string `protobuf:"bytes,4,opt,name=realm,proto3" json:"realm,omitempty"`
+	// The unique identifier of the test.
+	// For test results from ResultDB, see luci.resultdb.v1.TestResult.test_id.
+	TestId string `protobuf:"bytes,5,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// key:value pairs to specify the way of running a particular test.
+	// e.g. a specific bucket, builder and a test suite.
+	Variant *v1.Variant `protobuf:"bytes,6,opt,name=variant,proto3" json:"variant,omitempty"`
+	// Metadata key value pairs for this test result.
+	// It might describe this particular execution or the test case.
+	// A key can be repeated.
+	Tags []*v1.StringPair `protobuf:"bytes,25,rep,name=tags,proto3" json:"tags,omitempty"`
+	// Hash of the variant.
+	// hex(sha256(''.join(sorted('%s:%s\n' for k, v in variant.items())))).
+	VariantHash string `protobuf:"bytes,7,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// A failure reason describing why the test failed.
+	FailureReason *v1.FailureReason `protobuf:"bytes,8,opt,name=failure_reason,json=failureReason,proto3" json:"failure_reason,omitempty"`
+	// The bug tracking component corresponding to this test case, as identified
+	// by the test results system. If no information is available, this is
+	// unset.
+	BugTrackingComponent *v1.BugTrackingComponent `protobuf:"bytes,9,opt,name=bug_tracking_component,json=bugTrackingComponent,proto3" json:"bug_tracking_component,omitempty"`
+	// The point in time when the test case started to execute.
+	StartTime *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"`
+	// The amount of time the test case took to execute.
+	Duration *durationpb.Duration `protobuf:"bytes,11,opt,name=duration,proto3" json:"duration,omitempty"`
+	// The exonerations applied to the test verdict.
+	// An empty list indicates the test verdict this test result was a part of
+	// was not exonerated.
+	Exonerations []*TestExoneration `protobuf:"bytes,26,rep,name=exonerations,proto3" json:"exonerations,omitempty"`
+	// The presubmit run the test result was a part of (if any).
+	PresubmitRun *PresubmitRun `protobuf:"bytes,27,opt,name=presubmit_run,json=presubmitRun,proto3" json:"presubmit_run,omitempty"`
+	// The status of the build that contained this test result. Can be used
+	// to filter incomplete results (e.g. where build was cancelled or had
+	// an infra failure). Can also be used to filter builds with incomplete
+	// exonerations (e.g. build succeeded but some tests not exonerated).
+	// This is the build corresponding to ingested_invocation_id.
+	BuildStatus v1.BuildStatus `protobuf:"varint,28,opt,name=build_status,json=buildStatus,proto3,enum=weetbix.v1.BuildStatus" json:"build_status,omitempty"`
+	// Whether the build was critical to a presubmit run succeeding.
+	// If the build was not part of a presubmit run, this is unset.
+	BuildCritical *bool `protobuf:"varint,29,opt,name=build_critical,json=buildCritical,proto3,oneof" json:"build_critical,omitempty"`
+	// The unsubmitted changelists that were tested (if any).
+	// Changelists are sorted in ascending (host, change, patchset) order.
+	// Up to 10 changelists are captured.
+	Changelists []*v1.Changelist `protobuf:"bytes,23,rep,name=changelists,proto3" json:"changelists,omitempty"`
+	// The invocation from which this test result was ingested. This is
+	// the top-level invocation that was ingested, an "invocation" being
+	// a container of test results as identified by the source test result
+	// system.
+	//
+	// For ResultDB, Weetbix ingests invocations corresponding to
+	// buildbucket builds.
+	//
+	// All test results ingested from the same invocation (i.e. with the
+	// same ingested_invocation_id) will have the same partition time.
+	IngestedInvocationId string `protobuf:"bytes,14,opt,name=ingested_invocation_id,json=ingestedInvocationId,proto3" json:"ingested_invocation_id,omitempty"`
+	// The zero-based index for this test result, in the sequence of the
+	// ingested invocation's results for this test variant. Within the sequence,
+	// test results are ordered by start_time and then by test result ID.
+	// The first test result is 0, the last test result is
+	// ingested_invocation_result_count - 1.
+	IngestedInvocationResultIndex int64 `protobuf:"varint,15,opt,name=ingested_invocation_result_index,json=ingestedInvocationResultIndex,proto3" json:"ingested_invocation_result_index,omitempty"`
+	// The number of test results having this test variant in the ingested
+	// invocation.
+	IngestedInvocationResultCount int64 `protobuf:"varint,16,opt,name=ingested_invocation_result_count,json=ingestedInvocationResultCount,proto3" json:"ingested_invocation_result_count,omitempty"`
+	// Is the ingested invocation blocked by this test variant? This is
+	// only true if all (non-skipped) test results for this test variant
+	// (in the ingested invocation) are unexpected failures.
+	//
+	// Exoneration does not factor into this value; check exonerations
+	// to see if the impact of this ingested invocation being blocked was
+	// mitigated by exoneration.
+	IsIngestedInvocationBlocked bool `protobuf:"varint,17,opt,name=is_ingested_invocation_blocked,json=isIngestedInvocationBlocked,proto3" json:"is_ingested_invocation_blocked,omitempty"`
+	// The identifier of the test run the test ran in. Test results in different
+	// test runs are generally considered independent as they should be unable
+	// to leak state to one another.
+	//
+	// In Chrome and Chrome OS, a test run logically corresponds to a swarming
+	// task that runs tests, but this ID is not necessarily the ID of that
+	// task, but rather any other ID that is unique per such task.
+	//
+	// If test result system is ResultDB, this is the ID of the ResultDB
+	// invocation the test result was immediately contained within, not including
+	// any "invocations/" prefix.
+	TestRunId string `protobuf:"bytes,18,opt,name=test_run_id,json=testRunId,proto3" json:"test_run_id,omitempty"`
+	// The zero-based index for this test result, in the sequence of results
+	// having this test variant and test run. Within the sequence, test
+	// results are ordered by start_time and then by test result ID.
+	// The first test result is 0, the last test result is
+	// test_run_result_count - 1.
+	TestRunResultIndex int64 `protobuf:"varint,19,opt,name=test_run_result_index,json=testRunResultIndex,proto3" json:"test_run_result_index,omitempty"`
+	// The number of test results having this test variant and test run.
+	TestRunResultCount int64 `protobuf:"varint,20,opt,name=test_run_result_count,json=testRunResultCount,proto3" json:"test_run_result_count,omitempty"`
+	// Is the test run blocked by this test variant? This is only true if all
+	// (non-skipped) test results for this test variant (in the test run)
+	// are unexpected failures.
+	//
+	// Exoneration does not factor into this value; check exonerations
+	// to see if the impact of this test run being blocked was
+	// mitigated by exoneration.
+	IsTestRunBlocked bool `protobuf:"varint,21,opt,name=is_test_run_blocked,json=isTestRunBlocked,proto3" json:"is_test_run_blocked,omitempty"`
+}
+
+func (x *Failure) Reset() {
+	*x = Failure{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Failure) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Failure) ProtoMessage() {}
+
+func (x *Failure) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Failure.ProtoReflect.Descriptor instead.
+func (*Failure) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *Failure) GetTestResultId() *v1.TestResultId {
+	if x != nil {
+		return x.TestResultId
+	}
+	return nil
+}
+
+func (x *Failure) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *Failure) GetChunkIndex() int64 {
+	if x != nil {
+		return x.ChunkIndex
+	}
+	return 0
+}
+
+func (x *Failure) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *Failure) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *Failure) GetVariant() *v1.Variant {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *Failure) GetTags() []*v1.StringPair {
+	if x != nil {
+		return x.Tags
+	}
+	return nil
+}
+
+func (x *Failure) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *Failure) GetFailureReason() *v1.FailureReason {
+	if x != nil {
+		return x.FailureReason
+	}
+	return nil
+}
+
+func (x *Failure) GetBugTrackingComponent() *v1.BugTrackingComponent {
+	if x != nil {
+		return x.BugTrackingComponent
+	}
+	return nil
+}
+
+func (x *Failure) GetStartTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.StartTime
+	}
+	return nil
+}
+
+func (x *Failure) GetDuration() *durationpb.Duration {
+	if x != nil {
+		return x.Duration
+	}
+	return nil
+}
+
+func (x *Failure) GetExonerations() []*TestExoneration {
+	if x != nil {
+		return x.Exonerations
+	}
+	return nil
+}
+
+func (x *Failure) GetPresubmitRun() *PresubmitRun {
+	if x != nil {
+		return x.PresubmitRun
+	}
+	return nil
+}
+
+func (x *Failure) GetBuildStatus() v1.BuildStatus {
+	if x != nil {
+		return x.BuildStatus
+	}
+	return v1.BuildStatus(0)
+}
+
+func (x *Failure) GetBuildCritical() bool {
+	if x != nil && x.BuildCritical != nil {
+		return *x.BuildCritical
+	}
+	return false
+}
+
+func (x *Failure) GetChangelists() []*v1.Changelist {
+	if x != nil {
+		return x.Changelists
+	}
+	return nil
+}
+
+func (x *Failure) GetIngestedInvocationId() string {
+	if x != nil {
+		return x.IngestedInvocationId
+	}
+	return ""
+}
+
+func (x *Failure) GetIngestedInvocationResultIndex() int64 {
+	if x != nil {
+		return x.IngestedInvocationResultIndex
+	}
+	return 0
+}
+
+func (x *Failure) GetIngestedInvocationResultCount() int64 {
+	if x != nil {
+		return x.IngestedInvocationResultCount
+	}
+	return 0
+}
+
+func (x *Failure) GetIsIngestedInvocationBlocked() bool {
+	if x != nil {
+		return x.IsIngestedInvocationBlocked
+	}
+	return false
+}
+
+func (x *Failure) GetTestRunId() string {
+	if x != nil {
+		return x.TestRunId
+	}
+	return ""
+}
+
+func (x *Failure) GetTestRunResultIndex() int64 {
+	if x != nil {
+		return x.TestRunResultIndex
+	}
+	return 0
+}
+
+func (x *Failure) GetTestRunResultCount() int64 {
+	if x != nil {
+		return x.TestRunResultCount
+	}
+	return 0
+}
+
+func (x *Failure) GetIsTestRunBlocked() bool {
+	if x != nil {
+		return x.IsTestRunBlocked
+	}
+	return false
+}
+
+// Weetbix internal representation of a test exoneration.
+type TestExoneration struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The machine-readable reason for the exoneration.
+	Reason v1.ExonerationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=weetbix.v1.ExonerationReason" json:"reason,omitempty"`
+}
+
+func (x *TestExoneration) Reset() {
+	*x = TestExoneration{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestExoneration) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestExoneration) ProtoMessage() {}
+
+func (x *TestExoneration) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestExoneration.ProtoReflect.Descriptor instead.
+func (*TestExoneration) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *TestExoneration) GetReason() v1.ExonerationReason {
+	if x != nil {
+		return x.Reason
+	}
+	return v1.ExonerationReason(0)
+}
+
+// Weetbix internal representation of a presubmit run (e.g. LUCI CV Run).
+type PresubmitRun struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Identity of the presubmit run that contains this test result.
+	// This should be unique per "CQ+1"/"CQ+2" attempt on gerrit.
+	//
+	// One presumbit run MAY have many ingested invocation IDs (e.g. for its
+	// various tryjobs), but every ingested invocation ID only ever has one
+	// presubmit run ID (if any).
+	//
+	// All test results for the same presubmit run will have one
+	// partition_time.
+	//
+	// If the test result was not collected as part of a presubmit run,
+	// this is unset.
+	PresubmitRunId *v1.PresubmitRunId `protobuf:"bytes,1,opt,name=presubmit_run_id,json=presubmitRunId,proto3" json:"presubmit_run_id,omitempty"`
+	// The owner of the presubmit run (if any).
+	// This is the owner of the CL on which CQ+1/CQ+2 was clicked
+	// (even in case of presubmit run with multiple CLs).
+	// There is scope for this field to become an email address if privacy
+	// approval is obtained, until then it is "automation" (for automation
+	// service accounts) and "user" otherwise.
+	Owner string `protobuf:"bytes,2,opt,name=owner,proto3" json:"owner,omitempty"`
+	// The mode of the presubmit run. E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+	Mode v1.PresubmitRunMode `protobuf:"varint,3,opt,name=mode,proto3,enum=weetbix.v1.PresubmitRunMode" json:"mode,omitempty"`
+	// The presubmit run's ending status. E.g. SUCCESS, FAILURE, CANCELED.
+	Status v1.PresubmitRunStatus `protobuf:"varint,4,opt,name=status,proto3,enum=weetbix.v1.PresubmitRunStatus" json:"status,omitempty"`
+}
+
+func (x *PresubmitRun) Reset() {
+	*x = PresubmitRun{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PresubmitRun) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PresubmitRun) ProtoMessage() {}
+
+func (x *PresubmitRun) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use PresubmitRun.ProtoReflect.Descriptor instead.
+func (*PresubmitRun) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *PresubmitRun) GetPresubmitRunId() *v1.PresubmitRunId {
+	if x != nil {
+		return x.PresubmitRunId
+	}
+	return nil
+}
+
+func (x *PresubmitRun) GetOwner() string {
+	if x != nil {
+		return x.Owner
+	}
+	return ""
+}
+
+func (x *PresubmitRun) GetMode() v1.PresubmitRunMode {
+	if x != nil {
+		return x.Mode
+	}
+	return v1.PresubmitRunMode(0)
+}
+
+func (x *PresubmitRun) GetStatus() v1.PresubmitRunStatus {
+	if x != nil {
+		return x.Status
+	}
+	return v1.PresubmitRunStatus(0)
+}
+
+var File_infra_appengine_weetbix_internal_clustering_proto_failure_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDesc = []byte{
+	0x0a, 0x3f, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x2f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x12, 0x1b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72,
+	0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x1a, 0x1f,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f,
+	0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
+	0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+	0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
+	0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65,
+	0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76,
+	0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x31,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31,
+	0x2f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x1a, 0x35, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69,
+	0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x76, 0x31, 0x2f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73,
+	0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x49, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e,
+	0x6b, 0x12, 0x40, 0x0a, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e,
+	0x67, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x73, 0x22, 0x8b, 0x0b, 0x0a, 0x07, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x12,
+	0x3e, 0x0a, 0x0e, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x69,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49,
+	0x64, 0x52, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49, 0x64, 0x12,
+	0x41, 0x0a, 0x0e, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d,
+	0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x52, 0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69,
+	0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x6e, 0x64, 0x65,
+	0x78, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x6e,
+	0x64, 0x65, 0x78, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73,
+	0x74, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74,
+	0x49, 0x64, 0x12, 0x2d, 0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x06, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e,
+	0x74, 0x12, 0x2a, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x19, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x16, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72,
+	0x69, 0x6e, 0x67, 0x50, 0x61, 0x69, 0x72, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x21, 0x0a,
+	0x0c, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x07, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68,
+	0x12, 0x40, 0x0a, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73,
+	0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x65, 0x61,
+	0x73, 0x6f, 0x6e, 0x52, 0x0d, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x65, 0x61, 0x73,
+	0x6f, 0x6e, 0x12, 0x56, 0x0a, 0x16, 0x62, 0x75, 0x67, 0x5f, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69,
+	0x6e, 0x67, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x18, 0x09, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x42, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x52, 0x14, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e,
+	0x67, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74,
+	0x61, 0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+	0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+	0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72,
+	0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x35, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x50, 0x0a, 0x0c,
+	0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x1a, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74,
+	0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67,
+	0x2e, 0x54, 0x65, 0x73, 0x74, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x52, 0x0c, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x4e,
+	0x0a, 0x0d, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x18,
+	0x1b, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x69, 0x6e, 0x67, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e,
+	0x52, 0x0c, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x12, 0x3a,
+	0x0a, 0x0c, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x1c,
+	0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0b, 0x62,
+	0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2a, 0x0a, 0x0e, 0x62, 0x75,
+	0x69, 0x6c, 0x64, 0x5f, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x18, 0x1d, 0x20, 0x01,
+	0x28, 0x08, 0x48, 0x00, 0x52, 0x0d, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x72, 0x69, 0x74, 0x69,
+	0x63, 0x61, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x38, 0x0a, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65,
+	0x6c, 0x69, 0x73, 0x74, 0x73, 0x18, 0x17, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c,
+	0x69, 0x73, 0x74, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73,
+	0x12, 0x34, 0x0a, 0x16, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76,
+	0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x14, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x47, 0x0a, 0x20, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74,
+	0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65,
+	0x73, 0x75, 0x6c, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03,
+	0x52, 0x1d, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12,
+	0x47, 0x0a, 0x20, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6f,
+	0x75, 0x6e, 0x74, 0x18, 0x10, 0x20, 0x01, 0x28, 0x03, 0x52, 0x1d, 0x69, 0x6e, 0x67, 0x65, 0x73,
+	0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x43, 0x0a, 0x1e, 0x69, 0x73, 0x5f, 0x69,
+	0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x11, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x1b, 0x69, 0x73, 0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x1e, 0x0a,
+	0x0b, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x12, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x09, 0x74, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x12, 0x31, 0x0a,
+	0x15, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74,
+	0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x13, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x74, 0x65,
+	0x73, 0x74, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78,
+	0x12, 0x31, 0x0a, 0x15, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x72, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x14, 0x20, 0x01, 0x28, 0x03, 0x52,
+	0x12, 0x74, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x43, 0x6f,
+	0x75, 0x6e, 0x74, 0x12, 0x2d, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72,
+	0x75, 0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x10, 0x69, 0x73, 0x54, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b,
+	0x65, 0x64, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x63, 0x72, 0x69,
+	0x74, 0x69, 0x63, 0x61, 0x6c, 0x4a, 0x04, 0x08, 0x0c, 0x10, 0x0d, 0x4a, 0x04, 0x08, 0x18, 0x10,
+	0x19, 0x22, 0x48, 0x0a, 0x0f, 0x54, 0x65, 0x73, 0x74, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61,
+	0x73, 0x6f, 0x6e, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0xd4, 0x01, 0x0a, 0x0c,
+	0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x12, 0x44, 0x0a, 0x10,
+	0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x69, 0x64,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e,
+	0x49, 0x64, 0x52, 0x0e, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e,
+	0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x30, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e,
+	0x4d, 0x6f, 0x64, 0x65, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x36, 0x0a, 0x06, 0x73, 0x74,
+	0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69,
+	0x74, 0x52, 0x75, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x42, 0x40, 0x5a, 0x3e, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65,
+	0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e,
+	0x67, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69,
+	0x6e, 0x67, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescData = file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_goTypes = []interface{}{
+	(*Chunk)(nil),                   // 0: weetbix.internal.clustering.Chunk
+	(*Failure)(nil),                 // 1: weetbix.internal.clustering.Failure
+	(*TestExoneration)(nil),         // 2: weetbix.internal.clustering.TestExoneration
+	(*PresubmitRun)(nil),            // 3: weetbix.internal.clustering.PresubmitRun
+	(*v1.TestResultId)(nil),         // 4: weetbix.v1.TestResultId
+	(*timestamppb.Timestamp)(nil),   // 5: google.protobuf.Timestamp
+	(*v1.Variant)(nil),              // 6: weetbix.v1.Variant
+	(*v1.StringPair)(nil),           // 7: weetbix.v1.StringPair
+	(*v1.FailureReason)(nil),        // 8: weetbix.v1.FailureReason
+	(*v1.BugTrackingComponent)(nil), // 9: weetbix.v1.BugTrackingComponent
+	(*durationpb.Duration)(nil),     // 10: google.protobuf.Duration
+	(v1.BuildStatus)(0),             // 11: weetbix.v1.BuildStatus
+	(*v1.Changelist)(nil),           // 12: weetbix.v1.Changelist
+	(v1.ExonerationReason)(0),       // 13: weetbix.v1.ExonerationReason
+	(*v1.PresubmitRunId)(nil),       // 14: weetbix.v1.PresubmitRunId
+	(v1.PresubmitRunMode)(0),        // 15: weetbix.v1.PresubmitRunMode
+	(v1.PresubmitRunStatus)(0),      // 16: weetbix.v1.PresubmitRunStatus
+}
+var file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_depIdxs = []int32{
+	1,  // 0: weetbix.internal.clustering.Chunk.failures:type_name -> weetbix.internal.clustering.Failure
+	4,  // 1: weetbix.internal.clustering.Failure.test_result_id:type_name -> weetbix.v1.TestResultId
+	5,  // 2: weetbix.internal.clustering.Failure.partition_time:type_name -> google.protobuf.Timestamp
+	6,  // 3: weetbix.internal.clustering.Failure.variant:type_name -> weetbix.v1.Variant
+	7,  // 4: weetbix.internal.clustering.Failure.tags:type_name -> weetbix.v1.StringPair
+	8,  // 5: weetbix.internal.clustering.Failure.failure_reason:type_name -> weetbix.v1.FailureReason
+	9,  // 6: weetbix.internal.clustering.Failure.bug_tracking_component:type_name -> weetbix.v1.BugTrackingComponent
+	5,  // 7: weetbix.internal.clustering.Failure.start_time:type_name -> google.protobuf.Timestamp
+	10, // 8: weetbix.internal.clustering.Failure.duration:type_name -> google.protobuf.Duration
+	2,  // 9: weetbix.internal.clustering.Failure.exonerations:type_name -> weetbix.internal.clustering.TestExoneration
+	3,  // 10: weetbix.internal.clustering.Failure.presubmit_run:type_name -> weetbix.internal.clustering.PresubmitRun
+	11, // 11: weetbix.internal.clustering.Failure.build_status:type_name -> weetbix.v1.BuildStatus
+	12, // 12: weetbix.internal.clustering.Failure.changelists:type_name -> weetbix.v1.Changelist
+	13, // 13: weetbix.internal.clustering.TestExoneration.reason:type_name -> weetbix.v1.ExonerationReason
+	14, // 14: weetbix.internal.clustering.PresubmitRun.presubmit_run_id:type_name -> weetbix.v1.PresubmitRunId
+	15, // 15: weetbix.internal.clustering.PresubmitRun.mode:type_name -> weetbix.v1.PresubmitRunMode
+	16, // 16: weetbix.internal.clustering.PresubmitRun.status:type_name -> weetbix.v1.PresubmitRunStatus
+	17, // [17:17] is the sub-list for method output_type
+	17, // [17:17] is the sub-list for method input_type
+	17, // [17:17] is the sub-list for extension type_name
+	17, // [17:17] is the sub-list for extension extendee
+	0,  // [0:17] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_init() }
+func file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_init() {
+	if File_infra_appengine_weetbix_internal_clustering_proto_failure_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Chunk); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Failure); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestExoneration); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*PresubmitRun); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes[1].OneofWrappers = []interface{}{}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_internal_clustering_proto_failure_proto = out.File
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_rawDesc = nil
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_goTypes = nil
+	file_infra_appengine_weetbix_internal_clustering_proto_failure_proto_depIdxs = nil
+}
diff --git a/analysis/internal/clustering/proto/failure.proto b/analysis/internal/clustering/proto/failure.proto
new file mode 100644
index 0000000..e72bb9e
--- /dev/null
+++ b/analysis/internal/clustering/proto/failure.proto
@@ -0,0 +1,212 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.internal.clustering;
+
+import "google/protobuf/timestamp.proto";
+import "google/protobuf/duration.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+import "go.chromium.org/luci/analysis/proto/v1/changelist.proto";
+import "go.chromium.org/luci/analysis/proto/v1/failure_reason.proto";
+
+option go_package = "go.chromium.org/luci/analysis/internal/clustering/proto;clusteringpb";
+
+// Chunk is a set of unexpected test failures which are processed together
+// for efficiency.
+// Serialised and stored in GCS.
+message Chunk {
+  repeated Failure failures = 1;
+}
+
+// Weetbix internal representation of an unexpected test failure.
+// Next ID: 30.
+message Failure {
+  // The identity of the test result, as defined by the source system.
+  weetbix.v1.TestResultId test_result_id = 1;
+
+  // Timestamp representing the start of the data retention period. This acts
+  // as the partitioning key in time/date-partitioned tables.
+  google.protobuf.Timestamp partition_time = 2;
+
+  // The one-based index of this failure within the chunk. Assigned by
+  // Weetbix ingestion.
+  int64 chunk_index = 3;
+
+  // Security realm of the test result.
+  // For test results from ResultDB, this must be set. The format is
+  // "{LUCI_PROJECT}:{REALM_SUFFIX}", for example "chromium:ci".
+  string realm = 4;
+
+  // The unique identifier of the test.
+  // For test results from ResultDB, see luci.resultdb.v1.TestResult.test_id.
+  string test_id = 5;
+
+  // key:value pairs to specify the way of running a particular test.
+  // e.g. a specific bucket, builder and a test suite.
+  weetbix.v1.Variant variant = 6;
+
+  // Metadata key value pairs for this test result.
+  // It might describe this particular execution or the test case.
+  // A key can be repeated.
+  repeated weetbix.v1.StringPair tags = 25;
+
+  // Hash of the variant.
+  // hex(sha256(''.join(sorted('%s:%s\n' for k, v in variant.items())))).
+  string variant_hash = 7;
+
+  // A failure reason describing why the test failed.
+  weetbix.v1.FailureReason failure_reason = 8;
+
+  // The bug tracking component corresponding to this test case, as identified
+  // by the test results system. If no information is available, this is
+  // unset.
+  weetbix.v1.BugTrackingComponent bug_tracking_component = 9;
+
+  // The point in time when the test case started to execute.
+  google.protobuf.Timestamp start_time = 10;
+
+  // The amount of time the test case took to execute.
+  google.protobuf.Duration duration = 11;
+
+  reserved 12;
+
+  reserved 24;
+
+  // The exonerations applied to the test verdict.
+  // An empty list indicates the test verdict this test result was a part of
+  // was not exonerated.
+  repeated TestExoneration exonerations = 26;
+
+  // The presubmit run the test result was a part of (if any).
+  PresubmitRun presubmit_run = 27;
+
+  // The status of the build that contained this test result. Can be used
+  // to filter incomplete results (e.g. where build was cancelled or had
+  // an infra failure). Can also be used to filter builds with incomplete
+  // exonerations (e.g. build succeeded but some tests not exonerated).
+  // This is the build corresponding to ingested_invocation_id.
+  weetbix.v1.BuildStatus build_status = 28;
+
+  // Whether the build was critical to a presubmit run succeeding.
+  // If the build was not part of a presubmit run, this is unset.
+  optional bool build_critical = 29;
+
+  // The unsubmitted changelists that were tested (if any).
+  // Changelists are sorted in ascending (host, change, patchset) order.
+  // Up to 10 changelists are captured.
+  repeated weetbix.v1.Changelist changelists = 23;
+
+  // The invocation from which this test result was ingested. This is
+  // the top-level invocation that was ingested, an "invocation" being
+  // a container of test results as identified by the source test result
+  // system.
+  //
+  // For ResultDB, Weetbix ingests invocations corresponding to
+  // buildbucket builds.
+  //
+  // All test results ingested from the same invocation (i.e. with the
+  // same ingested_invocation_id) will have the same partition time.
+  string ingested_invocation_id = 14;
+
+  // The zero-based index for this test result, in the sequence of the
+  // ingested invocation's results for this test variant. Within the sequence,
+  // test results are ordered by start_time and then by test result ID.
+  // The first test result is 0, the last test result is
+  // ingested_invocation_result_count - 1.
+  int64 ingested_invocation_result_index = 15;
+
+  // The number of test results having this test variant in the ingested
+  // invocation.
+  int64 ingested_invocation_result_count = 16;
+
+  // Is the ingested invocation blocked by this test variant? This is
+  // only true if all (non-skipped) test results for this test variant
+  // (in the ingested invocation) are unexpected failures.
+  //
+  // Exoneration does not factor into this value; check exonerations
+  // to see if the impact of this ingested invocation being blocked was
+  // mitigated by exoneration.
+  bool is_ingested_invocation_blocked = 17;
+
+  // The identifier of the test run the test ran in. Test results in different
+  // test runs are generally considered independent as they should be unable
+  // to leak state to one another.
+  //
+  // In Chrome and Chrome OS, a test run logically corresponds to a swarming
+  // task that runs tests, but this ID is not necessarily the ID of that
+  // task, but rather any other ID that is unique per such task.
+  //
+  // If test result system is ResultDB, this is the ID of the ResultDB
+  // invocation the test result was immediately contained within, not including
+  // any "invocations/" prefix.
+  string test_run_id = 18;
+
+  // The zero-based index for this test result, in the sequence of results
+  // having this test variant and test run. Within the sequence, test
+  // results are ordered by start_time and then by test result ID.
+  // The first test result is 0, the last test result is
+  // test_run_result_count - 1.
+  int64 test_run_result_index = 19;
+
+  // The number of test results having this test variant and test run.
+  int64 test_run_result_count = 20;
+
+  // Is the test run blocked by this test variant? This is only true if all
+  // (non-skipped) test results for this test variant (in the test run)
+  // are unexpected failures.
+  //
+  // Exoneration does not factor into this value; check exonerations
+  // to see if the impact of this test run being blocked was
+  // mitigated by exoneration.
+  bool is_test_run_blocked = 21;
+}
+
+// Weetbix internal representation of a test exoneration.
+message TestExoneration {
+  // The machine-readable reason for the exoneration.
+  weetbix.v1.ExonerationReason reason = 1;
+}
+
+// Weetbix internal representation of a presubmit run (e.g. LUCI CV Run).
+message PresubmitRun {
+  // Identity of the presubmit run that contains this test result.
+  // This should be unique per "CQ+1"/"CQ+2" attempt on gerrit.
+  //
+  // One presumbit run MAY have many ingested invocation IDs (e.g. for its
+  // various tryjobs), but every ingested invocation ID only ever has one
+  // presubmit run ID (if any).
+  //
+  // All test results for the same presubmit run will have one
+  // partition_time.
+  //
+  // If the test result was not collected as part of a presubmit run,
+  // this is unset.
+  weetbix.v1.PresubmitRunId presubmit_run_id = 1;
+
+  // The owner of the presubmit run (if any).
+  // This is the owner of the CL on which CQ+1/CQ+2 was clicked
+  // (even in case of presubmit run with multiple CLs).
+  // There is scope for this field to become an email address if privacy
+  // approval is obtained, until then it is "automation" (for automation
+  // service accounts) and "user" otherwise.
+  string owner = 2;
+
+  // The mode of the presubmit run. E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+  weetbix.v1.PresubmitRunMode mode = 3;
+
+  // The presubmit run's ending status. E.g. SUCCESS, FAILURE, CANCELED.
+  weetbix.v1.PresubmitRunStatus status = 4;
+}
diff --git a/analysis/internal/clustering/proto/gen.go b/analysis/internal/clustering/proto/gen.go
new file mode 100644
index 0000000..1709a19
--- /dev/null
+++ b/analysis/internal/clustering/proto/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clusteringpb
+
+//go:generate cproto
diff --git a/analysis/internal/clustering/reclustering/main_test.go b/analysis/internal/clustering/reclustering/main_test.go
new file mode 100644
index 0000000..8e88729
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/reclustering/orchestrator/main_test.go b/analysis/internal/clustering/reclustering/orchestrator/main_test.go
new file mode 100644
index 0000000..6462804
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/orchestrator/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package orchestrator
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/reclustering/orchestrator/orchestrator.go b/analysis/internal/clustering/reclustering/orchestrator/orchestrator.go
new file mode 100644
index 0000000..da2d89f
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/orchestrator/orchestrator.go
@@ -0,0 +1,421 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package orchestrator
+
+import (
+	"context"
+	"math/big"
+	"sort"
+	"strings"
+	"time"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/tsmon"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/common/tsmon/types"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	worker "go.chromium.org/luci/analysis/internal/clustering/reclustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/services/reclustering"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+)
+
+var (
+	chunkGauge = metric.NewInt("weetbix/clustering/reclustering/chunk_count",
+		"The estimated number of chunks, by LUCI Project.",
+		&types.MetricMetadata{
+			Units: "chunks",
+		},
+		// The LUCI project.
+		field.String("project"),
+	)
+
+	workersGauge = metric.NewInt("weetbix/clustering/reclustering/worker_count",
+		"The number of workers performing reclustering, by LUCI Project.",
+		&types.MetricMetadata{
+			Units: "workers",
+		},
+		// The LUCI project.
+		field.String("project"),
+	)
+
+	progressGauge = metric.NewFloat("weetbix/clustering/reclustering/progress",
+		"The progress re-clustering, measured from 0 to 1, by LUCI Project.",
+		&types.MetricMetadata{
+			Units: "completions",
+		},
+		// The LUCI project.
+		field.String("project"),
+	)
+
+	lastCompletedGauge = metric.NewInt("weetbix/clustering/reclustering/last_completed",
+		"UNIX timstamp of the last completed re-clustering, by LUCI Project. "+
+			"Not reported until at least one re-clustering completes.",
+		&types.MetricMetadata{
+			Units: types.Seconds,
+		},
+		// The LUCI project.
+		field.String("project"),
+	)
+
+	// statusGauge reports the status of the orchestrator run. This covers
+	// only the orchestrator and not the success/failure of workers.
+	// Valid values are: "disabled", "success", "failure".
+	statusGauge = metric.NewString("weetbix/clustering/reclustering/orchestrator_status",
+		"Whether the orchestrator is enabled and succeeding.",
+		nil)
+)
+
+// CronHandler is the entry-point to the orchestrator that creates
+// reclustering jobs. It is triggered by a cron job configured in
+// cron.yaml.
+func CronHandler(ctx context.Context) error {
+	err := orchestrate(ctx)
+	if err != nil {
+		logging.Errorf(ctx, "Reclustering orchestrator encountered errors: %s", err)
+		return err
+	}
+	return nil
+}
+
+func init() {
+	// Register metrics as global metrics, which has the effort of
+	// resetting them after every flush.
+	tsmon.RegisterGlobalCallback(func(ctx context.Context) {
+		// Do nothing -- the metrics will be populated by the cron
+		// job itself and does not need to be triggered externally.
+	}, chunkGauge, workersGauge, progressGauge, lastCompletedGauge, statusGauge)
+}
+
+func orchestrate(ctx context.Context) error {
+	status := "failure"
+	defer func() {
+		// Closure for late binding.
+		statusGauge.Set(ctx, status)
+	}()
+
+	projectCfg, err := config.Projects(ctx)
+	if err != nil {
+		status = "failure"
+		return errors.Annotate(err, "get projects config").Err()
+	}
+	var projects []string
+	for project := range projectCfg {
+		projects = append(projects, project)
+	}
+	// The order of projects affects worker allocations if projects
+	// are entitled to fractional workers. Ensure the project order
+	// is stable to keep orchestrator behaviour as stable as possible.
+	sort.Strings(projects)
+
+	cfg, err := config.Get(ctx)
+	if err != nil {
+		status = "failure"
+		return errors.Annotate(err, "get service config").Err()
+	}
+
+	workers := int(cfg.ReclusteringWorkers)
+	intervalMinutes := int(cfg.ReclusteringIntervalMinutes)
+	if workers <= 0 {
+		status = "disabled"
+		logging.Warningf(ctx, "Reclustering is disabled because configured worker count is zero.")
+		return nil
+	}
+	if intervalMinutes <= 0 {
+		status = "disabled"
+		logging.Warningf(ctx, "Reclustering is disabled because configured reclustering interval is zero.")
+		return nil
+	}
+
+	err = orchestrateWithOptions(ctx, projects, workers, intervalMinutes)
+	if err != nil {
+		status = "failure"
+		return err
+	}
+	status = "success"
+	return nil
+}
+
+func orchestrateWithOptions(ctx context.Context, projects []string, workers, intervalMins int) error {
+	currentMinute := clock.Now(ctx).Truncate(time.Minute)
+	intervalDuration := time.Duration(intervalMins) * time.Minute
+	attemptStart := clock.Now(ctx).Truncate(intervalDuration)
+	if attemptStart != currentMinute {
+		logging.Infof(ctx, "Orchestrator ran, but determined the current run start %v"+
+			" does not match the current minute %v.", attemptStart, currentMinute)
+		return nil
+	}
+	attemptEnd := attemptStart.Add(intervalDuration)
+
+	workerCounts, err := projectWorkerCounts(ctx, projects, workers)
+	if err != nil {
+		return err
+	}
+
+	var errs []error
+	for _, project := range projects {
+		projectWorkers := workerCounts[project]
+		err := orchestrateProject(ctx, project, attemptStart, attemptEnd, projectWorkers)
+		if err != nil {
+			// If an error occurs with one project, capture it, but continue
+			// to avoid impacting other projects.
+			errs = append(errs, errors.Annotate(err, "project %s", project).Err())
+		}
+	}
+	if len(errs) > 0 {
+		return errors.NewMultiError(errs...)
+	}
+	return nil
+}
+
+// projectWorkerCounts distributes workers between LUCI projects.
+// The workers are allocated proportionately to the number of chunks in each
+// project, with a minimum of one worker per project.
+func projectWorkerCounts(ctx context.Context, projects []string, workers int) (map[string]int, error) {
+	chunksByProject := make(map[string]int64)
+	var totalChunks int64
+
+	txn, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+
+	for _, project := range projects {
+		estimate, err := state.EstimateChunks(txn, project)
+		if err != nil {
+			return nil, errors.Annotate(err, "estimating rows for project %s", project).Err()
+		}
+		chunksByProject[project] = int64(estimate)
+		totalChunks += int64(estimate)
+
+		chunkGauge.Set(ctx, int64(estimate), project)
+	}
+
+	// Each project gets at least one worker. The rest can be divided up
+	// according to project size.
+	freeWorkers := workers - len(projects)
+	if freeWorkers < 0 {
+		return nil, errors.New("more projects configured than workers")
+	}
+
+	result := make(map[string]int)
+	for _, project := range projects {
+
+		projectChunks := chunksByProject[project]
+		// Equiv. to math.Round((projectChunks / totalChunks) * freeWorkers)
+		// without floating-point precision issues.
+		additionalWorkers := int((projectChunks*int64(freeWorkers) + (totalChunks / 2)) / totalChunks)
+
+		totalChunks -= projectChunks
+		freeWorkers -= additionalWorkers
+
+		// Every project gets at least one worker, plus
+		// a number of workers depending on it size.
+		result[project] = 1 + additionalWorkers
+	}
+	return result, nil
+}
+
+// orchestrateProject starts a new reclustering run for the given project,
+// with the specified start and end time, and number of workers.
+func orchestrateProject(ctx context.Context, project string, attemptStart, attemptEnd time.Time, workers int) error {
+	projectCfg, err := config.Project(ctx, project)
+	if err != nil {
+		return errors.Annotate(err, "get project config").Err()
+	}
+	configVersion := projectCfg.LastUpdated.AsTime()
+
+	var metrics *metrics
+	_, err = span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		var err error
+		metrics, err = createProjectRun(ctx, project, attemptStart, attemptEnd, configVersion, workers)
+		return err
+	})
+	if err != nil {
+		return errors.Annotate(err, "create run").Err()
+	}
+
+	// Export metrics.
+	progressGauge.Set(ctx, float64(metrics.progress), project)
+	workersGauge.Set(ctx, int64(workers), project)
+	if metrics.lastCompleted != runs.StartingEpoch {
+		// Only report time since last completion once there is a last completion.
+		lastCompletedGauge.Set(ctx, metrics.lastCompleted.Unix(), project)
+	}
+
+	err = scheduleWorkers(ctx, project, attemptStart, attemptEnd, workers)
+	if err != nil {
+		return errors.Annotate(err, "schedule workers").Err()
+	}
+	return nil
+}
+
+type metrics struct {
+	// lastCompleted is the time since the last completed re-clustering.
+	// If no reclustering has been completed this is runs.StartingEpoch.
+	lastCompleted time.Time
+	// progress, measured from 0.0 to 1.0.
+	progress float64
+}
+
+// createProjectRun creates a new run entry for a project, returning whether
+// the previous run achieved its re-clustering goal (and any errors).
+func createProjectRun(ctx context.Context, project string, attemptStart, attemptEnd, latestConfigVersion time.Time, workers int) (*metrics, error) {
+	lastComplete, err := runs.ReadLastComplete(ctx, project)
+	if err != nil {
+		return nil, errors.Annotate(err, "read last complete run").Err()
+	}
+
+	lastRun, err := runs.ReadLast(ctx, project)
+	if err != nil {
+		return nil, errors.Annotate(err, "read last run").Err()
+	}
+
+	// run.Progress is a value between 0 and 1000 * lastRun.ShardCount.
+	progress := int(lastRun.Progress / lastRun.ShardCount)
+
+	if lastRun.AttemptTimestamp.After(attemptStart) {
+		return nil, errors.New("an attempt which overlaps the proposed attempt already exists")
+	}
+	newRun := &runs.ReclusteringRun{
+		Project:          project,
+		AttemptTimestamp: attemptEnd,
+		ShardCount:       int64(workers),
+		ShardsReported:   0,
+		Progress:         0,
+	}
+	if progress == 1000 {
+		rulesVersion, err := rules.ReadVersion(ctx, project)
+		if err != nil {
+			return nil, err
+		}
+		// Trigger re-clustering on rule predicate changes,
+		// as only the rule predicate matters for determining
+		// which clusters a failure is in.
+		newRun.RulesVersion = rulesVersion.Predicates
+		newRun.AlgorithmsVersion = algorithms.AlgorithmsVersion
+		if lastRun.AlgorithmsVersion > newRun.AlgorithmsVersion {
+			// Never roll back to an earlier algorithms version. Assume
+			// this orchestrator is running old code.
+			newRun.AlgorithmsVersion = lastRun.AlgorithmsVersion
+		}
+		newRun.ConfigVersion = latestConfigVersion
+		if lastRun.ConfigVersion.After(newRun.ConfigVersion) {
+			// Never roll back to an earlier config version. Assume
+			// this orchestrator still has old config cached.
+			newRun.ConfigVersion = lastRun.ConfigVersion
+		}
+	} else {
+		// It is foreseeable that re-clustering rules could have changed
+		// every time the orchestrator runs. If we update the rules
+		// version for each new run, we may be continuously
+		// re-clustering chunks early on in the keyspace without ever
+		// getting around to later chunks. To ensure progress, and ensure
+		// that every chunk gets a fair slice of re-clustering resources,
+		// keep the same re-clustering goals until the last run has completed.
+		newRun.RulesVersion = lastRun.RulesVersion
+		newRun.ConfigVersion = lastRun.ConfigVersion
+		newRun.AlgorithmsVersion = lastRun.AlgorithmsVersion
+	}
+	err = runs.Create(ctx, newRun)
+	if err != nil {
+		return nil, errors.Annotate(err, "create new run").Err()
+	}
+	metrics := &metrics{
+		progress: float64(progress) / 1000.0,
+	}
+	metrics.lastCompleted = lastComplete.AttemptTimestamp
+	return metrics, err
+}
+
+// scheduleWorkers creates reclustering tasks for the given project
+// and attempt. Workers are each assigned an equally large slice
+// of the keyspace to recluster.
+func scheduleWorkers(ctx context.Context, project string, attemptStart time.Time, attemptEnd time.Time, count int) error {
+	splits := workerSplits(count)
+	for i := 0; i < count; i++ {
+		start := splits[i]
+		end := splits[i+1]
+
+		// Space out the initial progress reporting of each task in the range
+		// [0,progressIntervalSeconds).
+		// This avoids creating contention on the ReclusteringRuns row.
+		reportOffset := time.Duration((int64(worker.ProgressInterval) / int64(count)) * int64(i))
+
+		task := &taskspb.ReclusterChunks{
+			Project:      project,
+			AttemptTime:  timestamppb.New(attemptEnd),
+			StartChunkId: start,
+			EndChunkId:   end,
+			State: &taskspb.ReclusterChunkState{
+				CurrentChunkId:       start,
+				NextReportDue:        timestamppb.New(attemptStart.Add(reportOffset)),
+				ReportedOnce:         false,
+				LastReportedProgress: 0,
+			},
+		}
+		err := reclustering.Schedule(ctx, task)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// workerSplits divides the chunk ID key space evenly into the given
+// number of partitions. count + 1 entries are returned; with the chunk ID
+// range for each partition being the range between two adjacent entries,
+// i.e. partition 0 is from result[0] (exclusive) to result[1] (inclusive),
+// partition 1 is from result[1] to result[2], and so on.
+func workerSplits(count int) []string {
+	var result []string
+	// "" indicates table start, which is the start of the first partition.
+	result = append(result, "")
+
+	var keyspaceSize big.Int
+	// keyspaceSize = 1 << 128  (for 128-bits of keyspace).
+	keyspaceSize.Lsh(big.NewInt(1), 128)
+
+	for i := 0; i < count; i++ {
+		// Identify the split point between two partitions.
+		// split = keyspaceSize * (i + 1) / count
+		var split big.Int
+		split.Mul(&keyspaceSize, big.NewInt(int64(i+1)))
+		split.Div(&split, big.NewInt(int64(count)))
+
+		// Subtract one to adjust for the upper bound being inclusive
+		// and not exclusive. (e.g. the last split should be (1 << 128) - 1,
+		// which is fffffff .... ffffff in hexadecimal,  not (1 << 128),
+		// which is a "1" with 32 zeroes in hexadecimal).
+		split.Sub(&split, big.NewInt(1))
+
+		// Convert the split to a hexadecimal string.
+		key := split.Text(16)
+		if len(key) < 32 {
+			// Pad the value with "0"s to get to the 32 hexadecimal
+			// character length of a chunk ID.
+			key = strings.Repeat("0", 32-len(key)) + key
+		}
+		result = append(result, key)
+	}
+	return result
+}
diff --git a/analysis/internal/clustering/reclustering/orchestrator/orchestrator_test.go b/analysis/internal/clustering/reclustering/orchestrator/orchestrator_test.go
new file mode 100644
index 0000000..5ef99a6
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/orchestrator/orchestrator_test.go
@@ -0,0 +1,369 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package orchestrator
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+	"go.chromium.org/luci/server/tq/tqtesting"
+	_ "go.chromium.org/luci/server/tq/txn/spanner"
+
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestOrchestrator(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		// Simulate the Orchestrator job running one second past the hour.
+		startTime := testclock.TestRecentTimeUTC.Truncate(time.Hour).Add(time.Second)
+		ctx, tc := testclock.UseTime(ctx, startTime)
+
+		ctx = memory.Use(ctx) // For config cache.
+		ctx, skdr := tq.TestingContext(ctx, nil)
+
+		cfg := &configpb.Config{
+			ReclusteringWorkers:         4,
+			ReclusteringIntervalMinutes: 5,
+		}
+		config.SetTestConfig(ctx, cfg)
+
+		testProjects := []string{"project-a", "project-b"}
+
+		testOrchestratorDoesNothing := func() {
+			beforeTasks := tasks(skdr)
+			beforeRuns := readRuns(ctx, testProjects)
+
+			err := CronHandler(ctx)
+			So(err, ShouldBeNil)
+
+			afterTasks := tasks(skdr)
+			afterRuns := readRuns(ctx, testProjects)
+			So(afterTasks, ShouldResembleProto, beforeTasks)
+			So(afterRuns, ShouldResemble, beforeRuns)
+		}
+
+		Convey("Without Projects", func() {
+			projectCfg := make(map[string]*configpb.ProjectConfig)
+			config.SetTestProjectConfig(ctx, projectCfg)
+
+			testOrchestratorDoesNothing()
+		})
+		Convey("With Projects", func() {
+			// Orchestrator only looks at the projects that have config,
+			// and the config version.
+			configVersionA := time.Date(2029, time.April, 1, 0, 0, 0, 1, time.UTC)
+			configVersionB := time.Date(2029, time.May, 1, 0, 0, 0, 1, time.UTC)
+			projectCfg := make(map[string]*configpb.ProjectConfig)
+			projectCfg["project-a"] = &configpb.ProjectConfig{
+				LastUpdated: timestamppb.New(configVersionA),
+			}
+			projectCfg["project-b"] = &configpb.ProjectConfig{
+				LastUpdated: timestamppb.New(configVersionB),
+			}
+			config.SetTestProjectConfig(ctx, projectCfg)
+
+			// Create chunks in project-b. After this, the row estimates
+			// for the projects should be:
+			// project-a: ~100
+			// project-b: ~450
+			var entries []*state.Entry
+			for i := 0; i < 450; i++ {
+				entries = append(entries, state.NewEntry(i).WithProject("project-b").Build())
+			}
+			_, err := state.CreateEntriesForTesting(ctx, entries)
+			So(err, ShouldBeNil)
+
+			rulesVersionB := time.Date(2020, time.January, 10, 9, 8, 7, 0, time.UTC)
+			rule := rules.NewRule(1).WithProject("project-b").WithPredicateLastUpdated(rulesVersionB).Build()
+			err = rules.SetRulesForTesting(ctx, []*rules.FailureAssociationRule{rule})
+			So(err, ShouldBeNil)
+
+			expectedAttemptStartTime := tc.Now().Truncate(5 * time.Minute)
+			expectedAttemptTime := expectedAttemptStartTime.Add(5 * time.Minute)
+			expectedTasks := []*taskspb.ReclusterChunks{
+				{
+					Project:      "project-a",
+					AttemptTime:  timestamppb.New(expectedAttemptTime),
+					StartChunkId: "",
+					EndChunkId:   state.EndOfTable,
+					State: &taskspb.ReclusterChunkState{
+						CurrentChunkId: "",
+						NextReportDue:  timestamppb.New(expectedAttemptStartTime),
+					},
+				},
+				{
+					Project:      "project-b",
+					AttemptTime:  timestamppb.New(expectedAttemptTime),
+					StartChunkId: "",
+					EndChunkId:   strings.Repeat("55", 15) + "54",
+					State: &taskspb.ReclusterChunkState{
+						CurrentChunkId: "",
+						NextReportDue:  timestamppb.New(expectedAttemptStartTime),
+					},
+				},
+				{
+					Project:      "project-b",
+					AttemptTime:  timestamppb.New(expectedAttemptTime),
+					StartChunkId: strings.Repeat("55", 15) + "54",
+					EndChunkId:   strings.Repeat("aa", 15) + "a9",
+					State: &taskspb.ReclusterChunkState{
+						CurrentChunkId: strings.Repeat("55", 15) + "54",
+						NextReportDue:  timestamppb.New(expectedAttemptStartTime.Add(10 * time.Second)),
+					},
+				},
+				{
+					Project:      "project-b",
+					AttemptTime:  timestamppb.New(expectedAttemptTime),
+					StartChunkId: strings.Repeat("aa", 15) + "a9",
+					EndChunkId:   state.EndOfTable,
+					State: &taskspb.ReclusterChunkState{
+						CurrentChunkId: strings.Repeat("aa", 15) + "a9",
+						NextReportDue:  timestamppb.New(expectedAttemptStartTime.Add(20 * time.Second)),
+					},
+				},
+			}
+			expectedRunA := &runs.ReclusteringRun{
+				Project:           "project-a",
+				AttemptTimestamp:  expectedAttemptTime,
+				AlgorithmsVersion: algorithms.AlgorithmsVersion,
+				ConfigVersion:     configVersionA,
+				RulesVersion:      rules.StartingEpoch,
+				ShardCount:        1,
+				ShardsReported:    0,
+				Progress:          0,
+			}
+			expectedRunB := &runs.ReclusteringRun{
+				Project:           "project-b",
+				AttemptTimestamp:  expectedAttemptTime,
+				AlgorithmsVersion: algorithms.AlgorithmsVersion,
+				ConfigVersion:     configVersionB,
+				RulesVersion:      rulesVersionB,
+				ShardCount:        3,
+				ShardsReported:    0,
+				Progress:          0,
+			}
+			expectedRuns := make(map[string]*runs.ReclusteringRun)
+			expectedRuns["project-a"] = expectedRunA
+			expectedRuns["project-b"] = expectedRunB
+
+			Convey("Disabled orchestrator does nothing", func() {
+				Convey("Workers is zero", func() {
+					cfg.ReclusteringWorkers = 0
+					config.SetTestConfig(ctx, cfg)
+
+					testOrchestratorDoesNothing()
+				})
+				Convey("Interval Minutes is zero", func() {
+					cfg.ReclusteringIntervalMinutes = 0
+					config.SetTestConfig(ctx, cfg)
+
+					testOrchestratorDoesNothing()
+				})
+			})
+			Convey("Schedules successfully without existing runs", func() {
+				err := CronHandler(ctx)
+				So(err, ShouldBeNil)
+
+				actualTasks := tasks(skdr)
+				So(actualTasks, ShouldResembleProto, expectedTasks)
+
+				actualRuns := readRuns(ctx, testProjects)
+				So(actualRuns, ShouldResemble, expectedRuns)
+			})
+			Convey("Schedules successfully with an existing run", func() {
+				existingRunB := &runs.ReclusteringRun{
+					Project: "project-b",
+					// So as not to overlap with the run that should be created.
+					AttemptTimestamp:  expectedAttemptTime.Add(-5 * time.Minute),
+					AlgorithmsVersion: 1,
+					ConfigVersion:     configVersionB.Add(-1 * time.Hour),
+					RulesVersion:      rulesVersionB.Add(-1 * time.Hour),
+					ShardCount:        10,
+					ShardsReported:    10,
+					// Complete.
+					Progress: 10 * 1000,
+				}
+
+				test := func() {
+					err = CronHandler(ctx)
+					So(err, ShouldBeNil)
+
+					actualTasks := tasks(skdr)
+					So(actualTasks, ShouldResembleProto, expectedTasks)
+
+					actualRuns := readRuns(ctx, testProjects)
+					So(actualRuns, ShouldResemble, expectedRuns)
+				}
+
+				Convey("existing complete run", func() {
+					err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{existingRunB})
+					So(err, ShouldBeNil)
+
+					// A run scheduled after an existing complete run should
+					// use the latest algorithms, config and rules available. So
+					// our expectations are unchanged.
+					test()
+				})
+				Convey("existing incomplete run", func() {
+					existingRunB.Progress = 500
+
+					err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{existingRunB})
+					So(err, ShouldBeNil)
+
+					// Expect the same algorithms and rules version to be used as
+					// the previous run, to ensure forward progress (if new rules
+					// are being constantly created, we don't want to be
+					// reclustering only the beginning of the workers' keyspaces).
+					expectedRunB.AlgorithmsVersion = existingRunB.AlgorithmsVersion
+					expectedRunB.ConfigVersion = existingRunB.ConfigVersion
+					expectedRunB.RulesVersion = existingRunB.RulesVersion
+					test()
+				})
+				Convey("existing complete run with later algorithms version", func() {
+					existingRunB.AlgorithmsVersion = algorithms.AlgorithmsVersion + 5
+
+					err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{existingRunB})
+					So(err, ShouldBeNil)
+
+					// If new algorithms are being rolled out, some GAE instances
+					// may be running old code. This includes the instance that
+					// runs the orchestrator.
+					// To simplify reasoning about re-clustering runs, and ensure
+					// correctness of re-clustering progress logic, we require
+					// the algorithms version of subsequent runs to always be
+					// non-decreasing.
+					expectedRunB.AlgorithmsVersion = existingRunB.AlgorithmsVersion
+					test()
+				})
+				Convey("existing complete run with later config version", func() {
+					existingRunB.ConfigVersion = configVersionB.Add(time.Hour)
+
+					err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{existingRunB})
+					So(err, ShouldBeNil)
+
+					// If new config is being rolled out, some GAE instances
+					// may still have old config cached. This includes the instance
+					// that runs the orchestrator.
+					// To simplify reasoning about re-clustering runs, and ensure
+					// correctness of re-clustering progress logic, we require
+					// the config version of subsequent runs to always be
+					// non-decreasing.
+					expectedRunB.ConfigVersion = existingRunB.ConfigVersion
+					test()
+				})
+			})
+			Convey("Does not schedule with an overlapping run", func() {
+				// This can occur if the reclustering interval changes.
+				runA := &runs.ReclusteringRun{
+					Project: "project-a",
+					// So as to overlap with the run that should be created.
+					AttemptTimestamp:  expectedAttemptTime.Add(-1 * time.Minute),
+					AlgorithmsVersion: 1,
+					ConfigVersion:     config.StartingEpoch,
+					RulesVersion:      rules.StartingEpoch,
+					ShardCount:        1,
+					ShardsReported:    1,
+					Progress:          500,
+				}
+				err := runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{runA})
+				So(err, ShouldBeNil)
+
+				// Expect only project-b to have been scheduled.
+				expectedRuns["project-a"] = runA
+				expectedTasks = expectedTasks[1:]
+
+				err = CronHandler(ctx)
+				So(err, ShouldErrLike, "an attempt which overlaps the proposed attempt already exists")
+
+				actualTasks := tasks(skdr)
+				So(actualTasks, ShouldResembleProto, expectedTasks)
+
+				actualRuns := readRuns(ctx, testProjects)
+				So(actualRuns, ShouldResemble, expectedRuns)
+			})
+			Convey("Does not schedule on off-interval minutes", func() {
+				// The test uses a 5-minute re-clustering interval, so
+				// minutes modulo 1, 2, 3 and 4 should not have runs start.
+				for i := 0; i < 4; i++ {
+					tc.Add(time.Minute)
+					testOrchestratorDoesNothing()
+				}
+
+				tc.Add(time.Minute)
+				err := CronHandler(ctx)
+				So(err, ShouldBeNil)
+
+				// Because we ran on the next scheduling interval five minutes later,
+				// expect all tasks and runs to be shifted five minutes back.
+				expectedAttemptTime = expectedAttemptTime.Add(5 * time.Minute)
+				for _, task := range expectedTasks {
+					task.AttemptTime = timestamppb.New(expectedAttemptTime)
+					task.State.NextReportDue = timestamppb.New(
+						task.State.NextReportDue.AsTime().Add(5 * time.Minute))
+				}
+				expectedRunA.AttemptTimestamp = expectedAttemptTime
+				expectedRunB.AttemptTimestamp = expectedAttemptTime
+
+				actualTasks := tasks(skdr)
+				So(actualTasks, ShouldResembleProto, expectedTasks)
+
+				actualRuns := readRuns(ctx, testProjects)
+				So(actualRuns, ShouldResemble, expectedRuns)
+			})
+		})
+	})
+}
+
+func tasks(s *tqtesting.Scheduler) []*taskspb.ReclusterChunks {
+	var tasks []*taskspb.ReclusterChunks
+	for _, pl := range s.Tasks().Payloads() {
+		task := pl.(*taskspb.ReclusterChunks)
+		tasks = append(tasks, task)
+	}
+	return tasks
+}
+
+func readRuns(ctx context.Context, projects []string) map[string]*runs.ReclusteringRun {
+	txn, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+
+	result := make(map[string]*runs.ReclusteringRun)
+	for _, project := range projects {
+		run, err := runs.ReadLast(txn, project)
+		So(err, ShouldBeNil)
+		result[project] = run
+	}
+	return result
+}
diff --git a/analysis/internal/clustering/reclustering/update.go b/analysis/internal/clustering/reclustering/update.go
new file mode 100644
index 0000000..4c140d7
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/update.go
@@ -0,0 +1,198 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/common/trace"
+	"go.chromium.org/luci/server/caching"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+)
+
+// TODO(crbug.com/1243174). Instrument the size of this cache so that we
+// can monitor it.
+var rulesCache = cache.NewRulesCache(caching.RegisterLRUCache(0))
+
+// Ruleset returns the cached ruleset for the given project. If a minimum
+// version of rule predicates is required, pass it as minimumPredicatesVersion.
+// If a strong read is required, pass cache.StrongRead.
+// Otherwise, pass rules.StartingEpoch.
+func Ruleset(ctx context.Context, project string, minimumPredicatesVersion time.Time) (*cache.Ruleset, error) {
+	ruleset, err := rulesCache.Ruleset(ctx, project, minimumPredicatesVersion)
+	if err != nil {
+		return nil, err
+	}
+	return ruleset, nil
+}
+
+// Analysis is the interface for cluster analysis.
+type Analysis interface {
+	// HandleUpdatedClusters handles (re-)clustered test results. It is called
+	// after the spanner transaction effecting the (re-)clustering has
+	// committed. commitTime is the Spanner time the transaction committed.
+	HandleUpdatedClusters(ctx context.Context, updates *clustering.Update, commitTime time.Time) error
+}
+
+// PendingUpdate is a (re-)clustering of a chunk of test results
+// that has not been applied to Spanner and/or sent for re-analysis
+// yet.
+type PendingUpdate struct {
+	// Chunk is the identity of the chunk which will be updated.
+	Chunk         state.ChunkKey
+	existingState *state.Entry
+	newClustering clustering.ClusterResults
+	updates       []*clustering.FailureUpdate
+}
+
+// PrepareUpdate will (re-)cluster the specific chunk of test results,
+// preparing an updated state for Spanner and updates to be exported
+// to analysis. The caller can determine how to batch these updates/
+// exports together, with help of the Size() method on the returned
+// pending update.
+//
+// If the chunk does not exist in Spanner, pass a *state.Entry
+// with project, chunkID, objectID and partitionTime set
+// but with LastUpdated set to its zero value. The chunk will be
+// clustered for the first time and saved to Spanner.
+//
+// If the chunk does exist in Spanner, pass the state.Entry read
+// from Spanner, along with the test results. The chunk will be
+// re-clustered and updated.
+func PrepareUpdate(ctx context.Context, ruleset *cache.Ruleset, config *compiledcfg.ProjectConfig, chunk *cpb.Chunk, existingState *state.Entry) (upd *PendingUpdate, err error) {
+	_, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/clustering/reclustering.PrepareUpdate")
+	s.Attribute("project", existingState.Project)
+	s.Attribute("chunkID", existingState.ChunkID)
+	defer func() { s.End(err) }()
+
+	exists := !existingState.LastUpdated.IsZero()
+	var existingClustering clustering.ClusterResults
+	if !exists {
+		existingClustering = algorithms.NewEmptyClusterResults(len(chunk.Failures))
+	} else {
+		if len(existingState.Clustering.Clusters) != len(chunk.Failures) {
+			return nil, fmt.Errorf("existing clustering does not match chunk; got clusters for %v test results, want %v", len(existingClustering.Clusters), len(chunk.Failures))
+		}
+		existingClustering = existingState.Clustering
+	}
+
+	newClustering := algorithms.Cluster(config, ruleset, existingClustering, clustering.FailuresFromProtos(chunk.Failures))
+
+	updates := prepareClusterUpdates(chunk, existingClustering, newClustering)
+
+	return &PendingUpdate{
+		Chunk:         state.ChunkKey{Project: existingState.Project, ChunkID: existingState.ChunkID},
+		existingState: existingState,
+		newClustering: newClustering,
+		updates:       updates,
+	}, nil
+}
+
+// Attempts to apply the update to Spanner.
+//
+// Important: Before calling this method, the caller should verify the chunks
+// in Spanner still have the same LastUpdatedTime as passed to PrepareUpdate,
+// in the same transaction as attempting this update.
+// This will prevent clobbering a concurrently applied update or create.
+//
+// In case of an update race, PrepareUpdate should be retried with a more
+// recent version of the chunk.
+func (p *PendingUpdate) ApplyToSpanner(ctx context.Context) error {
+	exists := !p.existingState.LastUpdated.IsZero()
+	if !exists {
+		clusterState := &state.Entry{
+			Project:       p.existingState.Project,
+			ChunkID:       p.existingState.ChunkID,
+			PartitionTime: p.existingState.PartitionTime,
+			ObjectID:      p.existingState.ObjectID,
+			Clustering:    p.newClustering,
+		}
+		if err := state.Create(ctx, clusterState); err != nil {
+			return err
+		}
+	} else {
+		if err := state.UpdateClustering(ctx, p.existingState, &p.newClustering); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// ApplyToAnalysis exports changed failures for re-analysis. The
+// Spanner commit time must be provided so that analysis has the
+// correct update chronology.
+func (p *PendingUpdate) ApplyToAnalysis(ctx context.Context, analysis Analysis, commitTime time.Time) error {
+	if len(p.updates) > 0 {
+		update := &clustering.Update{
+			Project: p.existingState.Project,
+			ChunkID: p.existingState.ChunkID,
+			Updates: p.updates,
+		}
+		if err := analysis.HandleUpdatedClusters(ctx, update, commitTime); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// EstimatedTransactionSize returns the estimated size of the
+// Spanner transaction, in bytes.
+func (p *PendingUpdate) EstimatedTransactionSize() int {
+	if len(p.updates) > 0 {
+		// This means we will be updating the clustering state in Spanner,
+		// not just the Version fields.
+		numClusters := 0
+		for _, cs := range p.newClustering.Clusters {
+			numClusters += len(cs)
+		}
+		// Est. 10 bytes per cluster, plus 200 bytes overhead.
+		return 200 + numClusters*10
+	}
+	// The clustering state has not changed, only
+	// AlgorithmsVersion and RulesVersion will be updated.
+	return 200
+}
+
+// FailuresUpdated returns the number of failures that will
+// exported for re-analysis as a result of the update.
+func (p *PendingUpdate) FailuresUpdated() int {
+	return len(p.updates)
+}
+
+func prepareClusterUpdates(chunk *cpb.Chunk, previousClustering clustering.ClusterResults, newClustering clustering.ClusterResults) []*clustering.FailureUpdate {
+	var updates []*clustering.FailureUpdate
+	for i, testResult := range chunk.Failures {
+		previousClusters := previousClustering.Clusters[i]
+		newClusters := newClustering.Clusters[i]
+
+		if !clustering.ClustersEqual(previousClusters, newClusters) {
+			update := &clustering.FailureUpdate{
+				TestResult:       testResult,
+				PreviousClusters: previousClusters,
+				NewClusters:      newClusters,
+			}
+			updates = append(updates, update)
+		}
+	}
+	return updates
+}
diff --git a/analysis/internal/clustering/reclustering/updates.go b/analysis/internal/clustering/reclustering/updates.go
new file mode 100644
index 0000000..2166bd5
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/updates.go
@@ -0,0 +1,131 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"context"
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/trace"
+	"go.chromium.org/luci/server/span"
+)
+
+const (
+	// Maximum size of pending Spanner transactions, in bytes. Once
+	// a transaction meets or exceeds this size, it will be committed.
+	maxTransactionBytes = 1000 * 1000
+	// Maximum number of failures to export to analysis at a time.
+	// Once the failures to be exported meets or exceeds this size, they will
+	// be committed.
+	maxAnalysisSize = 1000
+	// Maximum amount of time between Spanner commits. If no updates have
+	// been committed to Spanner for this time, a commit will be made at
+	// the next earliest opportunity. This limits how much work can be
+	// lost in case of an error or an update conflict.
+	maxPendingTime = 2 * time.Second
+)
+
+// UpdateRaceErr is the error returned by UpdateClustering if a concurrent
+// modification (or deletion) of a chunk is detected.
+var UpdateRaceErr = errors.New("concurrent modification to cluster")
+
+// PendingUpdates represents a pending set of chunk updates. It facilitates
+// batching updates together for efficiency.
+type PendingUpdates struct {
+	updates                 []*PendingUpdate
+	pendingTransactionBytes int
+	pendingAnalysisSize     int
+	lastCommit              time.Time
+}
+
+// NewPendingUpdates initialises a new PendingUpdates.
+func NewPendingUpdates(ctx context.Context) *PendingUpdates {
+	return &PendingUpdates{
+		updates:                 nil,
+		pendingTransactionBytes: 0,
+		pendingAnalysisSize:     0,
+		lastCommit:              clock.Now(ctx),
+	}
+}
+
+// Add adds the specified update to the set of pending updates.
+func (p *PendingUpdates) Add(update *PendingUpdate) {
+	p.updates = append(p.updates, update)
+	p.pendingTransactionBytes += update.EstimatedTransactionSize()
+	p.pendingAnalysisSize += update.FailuresUpdated()
+}
+
+// ShouldApply returns whether the updates should be applied now because
+// they have reached a maximum size or time limit.
+func (p *PendingUpdates) ShouldApply(ctx context.Context) bool {
+	return p.pendingTransactionBytes > maxTransactionBytes ||
+		p.pendingAnalysisSize > maxAnalysisSize ||
+		clock.Now(ctx).Sub(p.lastCommit) > maxPendingTime
+}
+
+// Apply applies the chunk updates to Spanner and exports them for re-analysis.
+// If some applications failed because of a concurrent modification, the method
+// returns UpdateRaceErr. In this case, the caller should construct the updates
+// again from a fresh read of the Clustering State and retry.
+// Note that some of the updates may have successfully applied.
+func (p *PendingUpdates) Apply(ctx context.Context, analysis Analysis) (err error) {
+	ctx, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/clustering/reclustering.Apply")
+	defer func() { s.End(err) }()
+
+	var appliedUpdates []*PendingUpdate
+	f := func(ctx context.Context) error {
+		var keys []state.ChunkKey
+		for _, pu := range p.updates {
+			keys = append(keys, pu.Chunk)
+		}
+		lastUpdated, err := state.ReadLastUpdated(ctx, keys)
+		if err != nil {
+			return errors.Annotate(err, "read last updated").Err()
+		}
+
+		appliedUpdates = nil
+		for i, pu := range p.updates {
+			actualLastUpdated := lastUpdated[i]
+			expectedLastUpdated := pu.existingState.LastUpdated
+			if !expectedLastUpdated.Equal(actualLastUpdated) {
+				// Our update raced with another update.
+				continue
+			}
+			if err := pu.ApplyToSpanner(ctx); err != nil {
+				return errors.Annotate(err, "apply to spanner").Err()
+			}
+			appliedUpdates = append(appliedUpdates, pu)
+		}
+		return nil
+	}
+	commitTime, err := span.ReadWriteTransaction(ctx, f)
+	if err != nil {
+		return err
+	}
+	for _, pu := range appliedUpdates {
+		if err := pu.ApplyToAnalysis(ctx, analysis, commitTime); err != nil {
+			return errors.Annotate(err, "export analysis").Err()
+		}
+	}
+	if len(appliedUpdates) != len(p.updates) {
+		// One more more updates raced.
+		return UpdateRaceErr
+	}
+	return nil
+}
diff --git a/analysis/internal/clustering/reclustering/worker.go b/analysis/internal/clustering/reclustering/worker.go
new file mode 100644
index 0000000..53a4ad2
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/worker.go
@@ -0,0 +1,350 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"math/big"
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/retry"
+	"go.chromium.org/luci/common/retry/transient"
+	"go.chromium.org/luci/common/trace"
+	"go.chromium.org/luci/server/span"
+
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+const (
+	// batchSize is the number of chunks to read from Spanner at a time.
+	batchSize = 10
+
+	// TargetTaskDuration is the desired duration of a re-clustering task.
+	// If a task completes before the reclustering run has completed, a
+	// continuation task will be scheduled.
+	//
+	// Longer durations will incur lower task queuing/re-queueing overhead,
+	// but limit the ability of autoscaling to move tasks between instances
+	// in response to load.
+	TargetTaskDuration = 2 * time.Second
+
+	// ProgressInterval is the amount of time between progress updates.
+	//
+	// Note that this is the frequency at which updates should
+	// be reported for a shard of work; individual tasks are usually
+	// much shorter lived and consequently most will not report any progress
+	// (unless it is time for the shard to report progress again).
+	ProgressInterval = 30 * time.Second
+)
+
+// ChunkStore is the interface for the blob store archiving chunks of test
+// results for later re-clustering.
+type ChunkStore interface {
+	// Get retrieves the chunk with the specified object ID and returns it.
+	Get(ctx context.Context, project, objectID string) (*cpb.Chunk, error)
+}
+
+// Worker provides methods to process re-clustering tasks. It is safe to be
+// used by multiple threads concurrently.
+type Worker struct {
+	chunkStore ChunkStore
+	analysis   Analysis
+}
+
+// NewWorker initialises a new Worker.
+func NewWorker(chunkStore ChunkStore, analysis Analysis) *Worker {
+	return &Worker{
+		chunkStore: chunkStore,
+		analysis:   analysis,
+	}
+}
+
+// taskContext provides objects relevant to working on a particular
+// re-clustering task.
+type taskContext struct {
+	worker        *Worker
+	task          *taskspb.ReclusterChunks
+	run           *runs.ReclusteringRun
+	progressToken *runs.ProgressToken
+	// nextReportDue is the time at which the next progress update is
+	// due.
+	nextReportDue time.Time
+	// currentChunkID is the exclusive lower bound of the range
+	// of ChunkIds still to re-cluster.
+	currentChunkID string
+}
+
+// Do works on a re-clustering task for approximately duration, returning a
+// continuation task (if the attempt end time has not been reached).
+//
+// Continuation tasks are used to better integrate with GAE autoscaling,
+// autoscaling work best when tasks are relatively small (so that work
+// can be moved between instances in real time).
+func (w *Worker) Do(ctx context.Context, task *taskspb.ReclusterChunks, duration time.Duration) (*taskspb.ReclusterChunks, error) {
+	if task.State == nil {
+		return nil, errors.New("task does not have state")
+	}
+
+	attemptTime := task.AttemptTime.AsTime()
+
+	run, err := runs.Read(span.Single(ctx), task.Project, attemptTime)
+	if err != nil {
+		return nil, errors.Annotate(err, "read run for task").Err()
+	}
+
+	if run.AlgorithmsVersion > algorithms.AlgorithmsVersion {
+		return nil, fmt.Errorf("running out-of-date algorithms version (task requires %v, worker running %v)",
+			run.AlgorithmsVersion, algorithms.AlgorithmsVersion)
+	}
+
+	progressState := &runs.ProgressState{
+		ReportedOnce:         task.State.ReportedOnce,
+		LastReportedProgress: int(task.State.LastReportedProgress),
+	}
+
+	pt := runs.NewProgressToken(task.Project, attemptTime, progressState)
+	tctx := &taskContext{
+		worker:         w,
+		task:           task,
+		run:            run,
+		progressToken:  pt,
+		nextReportDue:  task.State.NextReportDue.AsTime(),
+		currentChunkID: task.State.CurrentChunkId,
+	}
+
+	// finishTime is the (soft) deadline for the run.
+	finishTime := clock.Now(ctx).Add(duration)
+	if attemptTime.Before(finishTime) {
+		// Stop by the attempt time.
+		finishTime = attemptTime
+	}
+
+	var done bool
+	for clock.Now(ctx).Before(finishTime) && !done {
+		err := retry.Retry(ctx, transient.Only(retry.Default), func() error {
+			// Stop harder if retrying after the attemptTime, to avoid
+			// getting stuck in a retry loop if we are running in
+			// parallel with another worker.
+			if !clock.Now(ctx).Before(attemptTime) {
+				return nil
+			}
+			var err error
+			done, err = tctx.recluster(ctx)
+			return err
+		}, nil)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	var continuation *taskspb.ReclusterChunks
+	if finishTime.Before(attemptTime) && !done {
+		ps, err := pt.ExportState()
+		if err != nil {
+			return nil, err
+		}
+		continuation = &taskspb.ReclusterChunks{
+			Project:      task.Project,
+			AttemptTime:  task.AttemptTime,
+			StartChunkId: task.StartChunkId,
+			EndChunkId:   task.EndChunkId,
+			State: &taskspb.ReclusterChunkState{
+				CurrentChunkId:       tctx.currentChunkID,
+				NextReportDue:        timestamppb.New(tctx.nextReportDue),
+				ReportedOnce:         ps.ReportedOnce,
+				LastReportedProgress: int64(ps.LastReportedProgress),
+			},
+		}
+	}
+	return continuation, nil
+}
+
+// recluster tries to reclusters some chunks, advancing currentChunkID
+// as it succeeds. It returns 'true' if all chunks to be re-clustered by
+// the reclustering task were completed.
+func (t *taskContext) recluster(ctx context.Context) (done bool, err error) {
+	ctx, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/clustering/reclustering.recluster")
+	s.Attribute("project", t.task.Project)
+	s.Attribute("currentChunkID", t.currentChunkID)
+	defer func() { s.End(err) }()
+
+	readOpts := state.ReadNextOptions{
+		StartChunkID:      t.currentChunkID,
+		EndChunkID:        t.task.EndChunkId,
+		AlgorithmsVersion: t.run.AlgorithmsVersion,
+		ConfigVersion:     t.run.ConfigVersion,
+		RulesVersion:      t.run.RulesVersion,
+	}
+	entries, err := state.ReadNextN(span.Single(ctx), t.task.Project, readOpts, batchSize)
+	if err != nil {
+		return false, errors.Annotate(err, "read next chunk state").Err()
+	}
+	if len(entries) == 0 {
+		// We have finished re-clustering.
+		err = t.progressToken.ReportProgress(ctx, 1000)
+		if err != nil {
+			return true, errors.Annotate(err, "report progress").Err()
+		}
+		return true, nil
+	}
+
+	pendingUpdates := NewPendingUpdates(ctx)
+
+	for i, entry := range entries {
+		// Read the test results from GCS.
+		chunk, err := t.worker.chunkStore.Get(ctx, t.task.Project, entry.ObjectID)
+		if err != nil {
+			return false, errors.Annotate(err, "read chunk").Err()
+		}
+
+		// Obtain a recent ruleset of at least RulesVersion.
+		ruleset, err := Ruleset(ctx, t.task.Project, t.run.RulesVersion)
+		if err != nil {
+			return false, errors.Annotate(err, "obtain ruleset").Err()
+		}
+
+		// Obtain a recent configuration of at least ConfigVersion.
+		cfg, err := compiledcfg.Project(ctx, t.task.Project, t.run.ConfigVersion)
+		if err != nil {
+			return false, errors.Annotate(err, "obtain config").Err()
+		}
+
+		// Re-cluster the test results in spanner, then export
+		// the re-clustering to BigQuery for analysis.
+		update, err := PrepareUpdate(ctx, ruleset, cfg, chunk, entry)
+		if err != nil {
+			return false, errors.Annotate(err, "re-cluster chunk").Err()
+		}
+
+		pendingUpdates.Add(update)
+
+		if pendingUpdates.ShouldApply(ctx) || (i == len(entries)-1) {
+			if err := pendingUpdates.Apply(ctx, t.worker.analysis); err != nil {
+				if err == UpdateRaceErr {
+					// Our update raced with another update.
+					// This is retriable if we re-read the chunk again.
+					err = transient.Tag.Apply(err)
+				}
+				return false, err
+			}
+			pendingUpdates = NewPendingUpdates(ctx)
+
+			// Advance our position only on successful commit.
+			t.currentChunkID = entry.ChunkID
+
+			if err := t.reportProgress(ctx); err != nil {
+				return false, err
+			}
+		}
+	}
+
+	// More to do.
+	return false, nil
+}
+
+// reportProgress reports progress on the task, based on the current value
+// of t.currentChunkID. It can only be used to report interim progress (it
+// will never report a progress value of 1000).
+func (t *taskContext) reportProgress(ctx context.Context) error {
+	// Manage contention on the ReclusteringRun row by only periodically
+	// reporting progress.
+	if clock.Now(ctx).After(t.nextReportDue) {
+		progress, err := calculateProgress(t.task, t.currentChunkID)
+		if err != nil {
+			return errors.Annotate(err, "calculate progress").Err()
+		}
+
+		err = t.progressToken.ReportProgress(ctx, progress)
+		if err != nil {
+			return errors.Annotate(err, "report progress").Err()
+		}
+		t.nextReportDue = t.nextReportDue.Add(ProgressInterval)
+	}
+	return nil
+}
+
+// calculateProgress calculates the progress of the worker through the task.
+// Progress is the proportion of the keyspace re-clustered, as a value between
+// 0 and 1000 (i.e. 0 = 0%, 1000 = 100.0%).
+// 1000 is never returned by this method as the value passed is the nextChunkID
+// (i.e. the next chunkID to re-cluster), not the last completed chunk ID,
+// which implies progress is not complete.
+func calculateProgress(task *taskspb.ReclusterChunks, nextChunkID string) (int, error) {
+	nextID, err := chunkIDAsBigInt(nextChunkID)
+	if err != nil {
+		return 0, err
+	}
+	startID, err := chunkIDAsBigInt(task.StartChunkId)
+	if err != nil {
+		return 0, err
+	}
+	endID, err := chunkIDAsBigInt(task.EndChunkId)
+	if err != nil {
+		return 0, err
+	}
+	if startID.Cmp(endID) >= 0 {
+		return 0, fmt.Errorf("end chunk ID %q is before or equal to start %q", task.EndChunkId, task.StartChunkId)
+	}
+	if nextID.Cmp(startID) <= 0 {
+		// Start is exclusive, not inclusive.
+		return 0, fmt.Errorf("next chunk ID %q is before or equal to start %q", nextChunkID, task.StartChunkId)
+	}
+	if nextID.Cmp(endID) > 0 {
+		return 0, fmt.Errorf("next chunk ID %q is after end %q", nextChunkID, task.EndChunkId)
+	}
+
+	// progress = (((nextID - 1) - startID) * 1000) / (endID - startID)
+	var numerator big.Int
+	numerator.Sub(nextID, big.NewInt(1))
+	numerator.Sub(&numerator, startID)
+	numerator.Mul(&numerator, big.NewInt(1000))
+
+	var denominator big.Int
+	denominator.Sub(endID, startID)
+
+	var result big.Int
+	result.Div(&numerator, &denominator)
+
+	return int(result.Uint64()), nil
+}
+
+// chunkIDAsBigInt represents a 128-bit chunk ID
+// (normally represented as 32 lowercase hexadecimal characters)
+// as a big.Int.
+func chunkIDAsBigInt(chunkID string) (*big.Int, error) {
+	if chunkID == "" {
+		// "" indicates start of table. This is one before
+		// ID 00000 .... 00000.
+		return big.NewInt(-1), nil
+	}
+	idBytes, err := hex.DecodeString(chunkID)
+	if err != nil {
+		return nil, err
+	}
+	id := big.NewInt(0)
+	id.SetBytes(idBytes)
+	return id, nil
+}
diff --git a/analysis/internal/clustering/reclustering/worker_test.go b/analysis/internal/clustering/reclustering/worker_test.go
new file mode 100644
index 0000000..31dd258
--- /dev/null
+++ b/analysis/internal/clustering/reclustering/worker_test.go
@@ -0,0 +1,940 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/caching"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/durationpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/analysis/clusteredfailures"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/chunkstore"
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/clustering/state"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/pbutil"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const testProject = "testproject"
+
+// scenario represents a Weetbix system state used for testing.
+type scenario struct {
+	// clusteringState stores the test result-cluster inclusions
+	// for each test result in each chunk, and related metadata.
+	clusteringState []*state.Entry
+	// netBQExports are the test result-cluster insertions recorded
+	// in BigQuery, net of any deletions/updates.
+	netBQExports []*bqpb.ClusteredFailureRow
+	// config is the clustering configuration.
+	config *configpb.Clustering
+	// configVersion is the last updated time of the configuration.
+	configVersion time.Time
+	// rulesVersion is version of failure association rules.
+	rulesVersion rules.Version
+	// rules are the failure association rules.
+	rules []*rules.FailureAssociationRule
+	// testResults are the actual test failures ingested by Weetbix,
+	// organised in chunks by object ID.
+	testResultsByObjectID map[string]*cpb.Chunk
+}
+
+func TestReclustering(t *testing.T) {
+	Convey(`With Worker`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx, tc := testclock.UseTime(ctx, testclock.TestRecentTimeUTC)
+		ctx = caching.WithEmptyProcessCache(ctx) // For rules cache.
+		ctx = memory.Use(ctx)                    // For project config.
+
+		chunkStore := chunkstore.NewFakeClient()
+		clusteredFailures := clusteredfailures.NewFakeClient()
+		analysis := analysis.NewClusteringHandler(clusteredFailures)
+
+		worker := NewWorker(chunkStore, analysis)
+
+		attemptTime := tc.Now().Add(time.Minute * 10)
+		run := &runs.ReclusteringRun{
+			Project:           testProject,
+			AttemptTimestamp:  attemptTime,
+			AlgorithmsVersion: algorithms.AlgorithmsVersion,
+			RulesVersion:      time.Time{}, // To be set by the test.
+			ShardCount:        1,
+			ShardsReported:    0,
+			Progress:          0,
+		}
+		task := &taskspb.ReclusterChunks{
+			Project:      testProject,
+			AttemptTime:  timestamppb.New(attemptTime),
+			StartChunkId: "",
+			EndChunkId:   state.EndOfTable,
+			State: &taskspb.ReclusterChunkState{
+				CurrentChunkId:       "",
+				NextReportDue:        timestamppb.New(tc.Now()),
+				ReportedOnce:         false,
+				LastReportedProgress: 0,
+			},
+		}
+
+		setupScenario := func(s *scenario) {
+			// Create the run entry corresponding to the task.
+			run.RulesVersion = s.rulesVersion.Predicates
+			run.ConfigVersion = s.configVersion
+			So(runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{run}), ShouldBeNil)
+
+			// Set stored failure association rules.
+			So(rules.SetRulesForTesting(ctx, s.rules), ShouldBeNil)
+
+			cfg := map[string]*configpb.ProjectConfig{
+				testProject: {
+					Clustering:  s.config,
+					LastUpdated: timestamppb.New(s.configVersion),
+				},
+			}
+			So(config.SetTestProjectConfig(ctx, cfg), ShouldBeNil)
+
+			// Set stored test result chunks.
+			for objectID, chunk := range s.testResultsByObjectID {
+				chunkStore.Contents[chunkstore.FileName(testProject, objectID)] = chunk
+			}
+
+			// Set stored clustering state.
+			commitTime, err := state.CreateEntriesForTesting(ctx, s.clusteringState)
+			for _, e := range s.clusteringState {
+				e.LastUpdated = commitTime.In(time.UTC)
+			}
+			So(err, ShouldBeNil)
+		}
+
+		Convey(`Re-clustering`, func() {
+			testReclustering := func(initial *scenario, expected *scenario) {
+				setupScenario(initial)
+
+				// Run the task.
+				continuation, err := worker.Do(ctx, task, TargetTaskDuration)
+				So(err, ShouldBeNil)
+				So(continuation, ShouldBeNil)
+
+				// Final clustering state should be equal expected state.
+				actualState, err := state.ReadAllForTesting(ctx, testProject)
+				So(err, ShouldBeNil)
+				for _, as := range actualState {
+					// Clear last updated time to compare actual vs expected
+					// state based on row contents, not when the row was updated.
+					as.LastUpdated = time.Time{}
+				}
+				So(actualState, ShouldResemble, expected.clusteringState)
+
+				// BigQuery exports should correctly reflect the new
+				// test result-cluster inclusions.
+				exports := clusteredFailures.InsertionsByProject[testProject]
+				sortBQExport(exports)
+				netExports := flattenBigQueryExports(append(initial.netBQExports, exports...))
+				So(netExports, ShouldResembleProto, expected.netBQExports)
+
+				// Run is reported as complete.
+				actualRun, err := runs.Read(span.Single(ctx), testProject, run.AttemptTimestamp)
+				So(err, ShouldBeNil)
+				So(actualRun.Progress, ShouldEqual, 1000)
+			}
+
+			Convey("Already up-to-date", func() {
+				expected := newScenario().build()
+
+				// Start with up-to-date clustering.
+				s := newScenario().build()
+
+				testReclustering(s, expected)
+
+				// Further bound the expected behaviour. Not only
+				// should there be zero net changes to the BigQuery
+				// export, no changes should be written to BigQuery
+				// at all.
+				So(clusteredFailures.InsertionsByProject[testProject], ShouldBeEmpty)
+			})
+			Convey("From old algorithms", func() {
+				expected := newScenario().build()
+
+				// Start with an out of date clustering.
+				s := newScenario().withOldAlgorithms(true).build()
+
+				testReclustering(s, expected)
+			})
+			Convey("From old configuration", func() {
+				expected := newScenario().build()
+
+				// Start with clustering based on old configuration.
+				s := newScenario().withOldConfig(true).build()
+				s.config = expected.config
+				s.configVersion = expected.configVersion
+
+				testReclustering(s, expected)
+			})
+			Convey("From old rules", func() {
+				expected := newScenario().build()
+
+				// Start with clustering based on old rules.
+				s := newScenario().withOldRules(true).build()
+				s.rules = expected.rules
+				s.rulesVersion = expected.rulesVersion
+
+				testReclustering(s, expected)
+			})
+		})
+		Convey(`Worker respects end time`, func() {
+			expected := newScenario().build()
+
+			// Start with an out of date clustering.
+			s := newScenario().withOldAlgorithms(true).build()
+			s.rules = expected.rules
+			s.rulesVersion = expected.rulesVersion
+			setupScenario(s)
+
+			// Start the worker after the attempt time.
+			tc.Add(11 * time.Minute)
+			So(tc.Now(), ShouldHappenAfter, run.AttemptTimestamp)
+
+			// Run the task.
+			continuation, err := worker.Do(ctx, task, TargetTaskDuration)
+			So(err, ShouldBeNil)
+			So(continuation, ShouldBeNil)
+
+			// Clustering state should be same as the initial state.
+			actualState, err := state.ReadAllForTesting(ctx, testProject)
+			So(err, ShouldBeNil)
+			So(actualState, ShouldResemble, s.clusteringState)
+
+			// No changes written to BigQuery.
+			So(clusteredFailures.InsertionsByProject[testProject], ShouldBeEmpty)
+
+			// No progress is reported.
+			actualRun, err := runs.Read(span.Single(ctx), testProject, run.AttemptTimestamp)
+			So(err, ShouldBeNil)
+			So(actualRun.Progress, ShouldEqual, 0)
+		})
+		Convey(`Handles update/update races`, func() {
+			finalState := newScenario().build()
+
+			// Start with an out of date clustering.
+			s := newScenario().withOldAlgorithms(true).build()
+			s.rules = finalState.rules
+			s.rulesVersion = finalState.rulesVersion
+			setupScenario(s)
+
+			// Make reading a chunk's test results trigger updating
+			// its clustering state Spanner, to simulate an update/update race.
+			chunkIDByObjectID := make(map[string]string)
+			for _, state := range s.clusteringState {
+				chunkIDByObjectID[state.ObjectID] = state.ChunkID
+			}
+			chunkStore.GetCallack = func(objectID string) {
+				chunkID, ok := chunkIDByObjectID[objectID]
+
+				// Only simulate the update/update race once per chunk.
+				if !ok {
+					return
+				}
+				delete(chunkIDByObjectID, objectID)
+
+				_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					span.BufferWrite(ctx, spanutil.UpdateMap("ClusteringState", map[string]interface{}{
+						"Project": testProject,
+						"ChunkID": chunkID,
+						// Simulate a race with another update, that
+						// re-clustered the chunk to an algorithms version
+						// later than the one we know about.
+						"AlgorithmsVersion": algorithms.AlgorithmsVersion + 1,
+						"LastUpdated":       spanner.CommitTimestamp,
+					}))
+					return nil
+				})
+				So(err, ShouldBeNil)
+			}
+
+			// Run the worker with time advancing at 100 times speed,
+			// as the transaction retry logic sets timers which must be
+			// triggered.
+			runWithTimeAdvancing(tc, func() {
+				continuation, err := worker.Do(ctx, task, TargetTaskDuration)
+				So(err, ShouldBeNil)
+				So(continuation, ShouldBeNil)
+			})
+
+			// Because of update races, none of the chunks should have been
+			// re-clustered further.
+			expected := newScenario().withOldAlgorithms(true).build()
+			for _, es := range expected.clusteringState {
+				es.Clustering.AlgorithmsVersion = algorithms.AlgorithmsVersion + 1
+			}
+
+			actualState, err := state.ReadAllForTesting(ctx, testProject)
+			So(err, ShouldBeNil)
+			for _, as := range actualState {
+				as.LastUpdated = time.Time{}
+			}
+			So(actualState, ShouldResemble, expected.clusteringState)
+
+			// No changes written to BigQuery.
+			So(clusteredFailures.InsertionsByProject[testProject], ShouldBeEmpty)
+
+			// Run is reported as complete.
+			actualRun, err := runs.Read(span.Single(ctx), testProject, run.AttemptTimestamp)
+			So(err, ShouldBeNil)
+			So(actualRun.Progress, ShouldEqual, 1000)
+		})
+		Convey(`Worker running out of date algorithms`, func() {
+			run.AlgorithmsVersion = algorithms.AlgorithmsVersion + 1
+			run.ConfigVersion = config.StartingEpoch
+			run.RulesVersion = rules.StartingEpoch
+			So(runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{run}), ShouldBeNil)
+
+			continuation, err := worker.Do(ctx, task, TargetTaskDuration)
+			So(err, ShouldErrLike, "running out-of-date algorithms version")
+			So(continuation, ShouldBeNil)
+		})
+		Convey(`Continuation correctly scheduled`, func() {
+			run.RulesVersion = rules.StartingEpoch
+			run.ConfigVersion = config.StartingEpoch
+			So(runs.SetRunsForTesting(ctx, []*runs.ReclusteringRun{run}), ShouldBeNil)
+
+			// Leave no time for the task to run.
+			continuation, err := worker.Do(ctx, task, 0*time.Second)
+			So(err, ShouldBeNil)
+
+			// Continuation should be scheduled, matching original task.
+			So(continuation, ShouldResembleProto, task)
+		})
+	})
+}
+
+func TestProgress(t *testing.T) {
+	Convey(`Task assigned entire keyspace`, t, func() {
+		task := &taskspb.ReclusterChunks{
+			StartChunkId: "",
+			EndChunkId:   strings.Repeat("ff", 16),
+		}
+
+		progress, err := calculateProgress(task, strings.Repeat("00", 16))
+		So(err, ShouldBeNil)
+		So(progress, ShouldEqual, 0)
+
+		progress, err = calculateProgress(task, "80"+strings.Repeat("00", 15))
+		So(err, ShouldBeNil)
+		So(progress, ShouldEqual, 500)
+
+		progress, err = calculateProgress(task, strings.Repeat("ff", 16))
+		So(err, ShouldBeNil)
+		So(progress, ShouldEqual, 999)
+	})
+	Convey(`Task assigned partial keyspace`, t, func() {
+		// Consistent with the second shard, if the keyspace is split into
+		// three.
+		task := &taskspb.ReclusterChunks{
+			StartChunkId: strings.Repeat("55", 15) + "54",
+			EndChunkId:   strings.Repeat("aa", 15) + "a9",
+		}
+
+		progress, err := calculateProgress(task, strings.Repeat("55", 16))
+		So(err, ShouldBeNil)
+		So(progress, ShouldEqual, 0)
+
+		progress, err = calculateProgress(task, strings.Repeat("77", 16))
+		So(err, ShouldBeNil)
+		So(progress, ShouldEqual, 400)
+
+		progress, err = calculateProgress(task, strings.Repeat("aa", 15)+"a9")
+		So(err, ShouldBeNil)
+		So(progress, ShouldEqual, 999)
+	})
+}
+
+func runWithTimeAdvancing(tc testclock.TestClock, cb func()) {
+	ticker := time.NewTicker(time.Millisecond)
+	done := make(chan bool)
+	go func() {
+		for {
+			select {
+			case <-done:
+				return
+			case <-ticker.C:
+				// Run with time advancing at 100 times speed, to
+				// avoid holding up tests unnecessarily.
+				tc.Add(time.Millisecond * 100)
+			}
+		}
+	}()
+
+	cb()
+
+	ticker.Stop()
+	done <- true
+}
+
+// flattenBigQueryExports returns the latest inclusion row for
+// each test result-cluster, from a list of BigQuery exports.
+// The returned set of rows do not have last updated time set.
+func flattenBigQueryExports(exports []*bqpb.ClusteredFailureRow) []*bqpb.ClusteredFailureRow {
+	keyValue := make(map[string]*bqpb.ClusteredFailureRow)
+	for _, row := range exports {
+		key := bigQueryKey(row)
+		existingRow, ok := keyValue[key]
+		if ok && existingRow.LastUpdated.AsTime().After(row.LastUpdated.AsTime()) {
+			continue
+		}
+		keyValue[key] = row
+	}
+	var result []*bqpb.ClusteredFailureRow
+	for _, row := range keyValue {
+		if row.IsIncluded {
+			clonedRow := proto.Clone(row).(*bqpb.ClusteredFailureRow)
+			clonedRow.LastUpdated = nil
+			result = append(result, clonedRow)
+		}
+	}
+	sortBQExport(result)
+	return result
+}
+
+func bigQueryKey(row *bqpb.ClusteredFailureRow) string {
+	return fmt.Sprintf("%q/%q/%q/%q", row.ClusterAlgorithm, row.ClusterId, row.TestResultSystem, row.TestResultId)
+}
+
+type testResultBuilder struct {
+	uniqifier     int
+	failureReason *pb.FailureReason
+	testName      string
+}
+
+func newTestResult(uniqifier int) *testResultBuilder {
+	return &testResultBuilder{
+		uniqifier: uniqifier,
+		testName:  fmt.Sprintf("ninja://test_name/%v", uniqifier),
+		failureReason: &pb.FailureReason{
+			PrimaryErrorMessage: fmt.Sprintf("Failure reason %v.", uniqifier),
+		},
+	}
+}
+
+func (b *testResultBuilder) withTestName(name string) *testResultBuilder {
+	b.testName = name
+	return b
+}
+
+func (b *testResultBuilder) withFailureReason(reason *pb.FailureReason) *testResultBuilder {
+	b.failureReason = reason
+	return b
+}
+
+func (b *testResultBuilder) buildFailure() *cpb.Failure {
+	keyHash := sha256.Sum256([]byte("variantkey:value\n"))
+	buildCritical := b.uniqifier%2 == 0
+	return &cpb.Failure{
+		TestResultId:  pbutil.TestResultIDFromResultDB(fmt.Sprintf("invocations/testrun-%v/tests/test-name-%v/results/%v", b.uniqifier, b.uniqifier, b.uniqifier)),
+		PartitionTime: timestamppb.New(time.Date(2020, time.April, 1, 2, 3, 4, 0, time.UTC)),
+		ChunkIndex:    -1, // To be populated by caller.
+		Realm:         "testproject:realm",
+		TestId:        b.testName,
+		Variant:       &pb.Variant{Def: map[string]string{"variantkey": "value"}},
+		VariantHash:   hex.EncodeToString(keyHash[:]),
+		FailureReason: b.failureReason,
+		BugTrackingComponent: &pb.BugTrackingComponent{
+			System:    "monorail",
+			Component: "Component>MyComponent",
+		},
+		StartTime: timestamppb.New(time.Date(2025, time.March, 2, 2, 2, 2, b.uniqifier, time.UTC)),
+		Duration:  durationpb.New(time.Duration(b.uniqifier) * time.Second),
+		Exonerations: []*cpb.TestExoneration{
+			{
+				Reason: pb.ExonerationReason(1 + (b.uniqifier % 3)),
+			},
+		},
+		PresubmitRun: &cpb.PresubmitRun{
+			PresubmitRunId: &pb.PresubmitRunId{
+				System: "luci-cv",
+				Id:     fmt.Sprintf("run-%v", b.uniqifier),
+			},
+			Owner:  fmt.Sprintf("owner-%v", b.uniqifier),
+			Mode:   pb.PresubmitRunMode(1 + b.uniqifier%3),
+			Status: pb.PresubmitRunStatus(3 - b.uniqifier%3),
+		},
+		BuildStatus:   pb.BuildStatus(1 + b.uniqifier%4),
+		BuildCritical: &buildCritical,
+		Changelists: []*pb.Changelist{
+			{
+				Host:     "chromium",
+				Change:   12345,
+				Patchset: int32(b.uniqifier),
+			},
+		},
+
+		IngestedInvocationId:          fmt.Sprintf("invocation-%v", b.uniqifier),
+		IngestedInvocationResultIndex: int64(b.uniqifier + 1),
+		IngestedInvocationResultCount: int64(b.uniqifier*2 + 1),
+		IsIngestedInvocationBlocked:   b.uniqifier%3 == 0,
+
+		TestRunId:          fmt.Sprintf("test-run-%v", b.uniqifier),
+		TestRunResultIndex: int64((int64(b.uniqifier) / 2) + 1),
+		TestRunResultCount: int64(b.uniqifier + 1),
+		IsTestRunBlocked:   b.uniqifier%2 == 0,
+	}
+}
+
+// buildBQExport returns the expected test result-cluster inclusion rows that
+// would appear in BigQuery, if the test result was in the given clusters.
+// Note that deletions are not returned; these are simply the 'net' rows that
+// would be expected.
+func (b *testResultBuilder) buildBQExport(clusterIDs []clustering.ClusterID) []*bqpb.ClusteredFailureRow {
+	keyHash := sha256.Sum256([]byte("variantkey:value\n"))
+	var inBugCluster bool
+	for _, cID := range clusterIDs {
+		if cID.IsBugCluster() {
+			inBugCluster = true
+		}
+	}
+
+	presubmitRunStatus := pb.PresubmitRunStatus(3 - b.uniqifier%3).String()
+	if !strings.HasPrefix(presubmitRunStatus, "PRESUBMIT_RUN_STATUS_") {
+		panic("PresubmitRunStatus does not have expected prefix: " + presubmitRunStatus)
+	}
+	presubmitRunStatus = strings.TrimPrefix(presubmitRunStatus, "PRESUBMIT_RUN_STATUS_")
+
+	var results []*bqpb.ClusteredFailureRow
+	for _, cID := range clusterIDs {
+		result := &bqpb.ClusteredFailureRow{
+			ClusterAlgorithm: cID.Algorithm,
+			ClusterId:        cID.ID,
+			TestResultSystem: "resultdb",
+			TestResultId:     fmt.Sprintf("invocations/testrun-%v/tests/test-name-%v/results/%v", b.uniqifier, b.uniqifier, b.uniqifier),
+			LastUpdated:      nil, // To be set by caller.
+
+			PartitionTime:              timestamppb.New(time.Date(2020, time.April, 1, 2, 3, 4, 0, time.UTC)),
+			IsIncluded:                 true,
+			IsIncludedWithHighPriority: cID.IsBugCluster() || !inBugCluster,
+
+			ChunkId:    "", // To be set by caller.
+			ChunkIndex: 0,  // To be set by caller.
+
+			Realm:  "testproject:realm",
+			TestId: b.testName,
+			Variant: []*pb.StringPair{
+				{
+					Key:   "variantkey",
+					Value: "value",
+				},
+			},
+			VariantHash:          hex.EncodeToString(keyHash[:]),
+			FailureReason:        b.failureReason,
+			BugTrackingComponent: &pb.BugTrackingComponent{System: "monorail", Component: "Component>MyComponent"},
+			StartTime:            timestamppb.New(time.Date(2025, time.March, 2, 2, 2, 2, b.uniqifier, time.UTC)),
+			Duration:             float64(b.uniqifier * 1.0),
+			Exonerations: []*bqpb.ClusteredFailureRow_TestExoneration{
+				{
+					Reason: pb.ExonerationReason(1 + (b.uniqifier % 3)),
+				},
+			},
+			PresubmitRunId: &pb.PresubmitRunId{
+				System: "luci-cv",
+				Id:     fmt.Sprintf("run-%v", b.uniqifier),
+			},
+			PresubmitRunOwner:  fmt.Sprintf("owner-%v", b.uniqifier),
+			PresubmitRunMode:   pb.PresubmitRunMode(1 + b.uniqifier%3).String(),
+			PresubmitRunStatus: presubmitRunStatus,
+			BuildStatus:        strings.TrimPrefix(pb.BuildStatus(1+b.uniqifier%4).String(), "BUILD_STATUS_"),
+			BuildCritical:      b.uniqifier%2 == 0,
+			Changelists: []*pb.Changelist{
+				{
+					Host:     "chromium",
+					Change:   12345,
+					Patchset: int32(b.uniqifier),
+				},
+			},
+
+			IngestedInvocationId:          fmt.Sprintf("invocation-%v", b.uniqifier),
+			IngestedInvocationResultIndex: int64(b.uniqifier + 1),
+			IngestedInvocationResultCount: int64(b.uniqifier*2 + 1),
+			IsIngestedInvocationBlocked:   b.uniqifier%3 == 0,
+
+			TestRunId:          fmt.Sprintf("test-run-%v", b.uniqifier),
+			TestRunResultIndex: int64((int64(b.uniqifier) / 2) + 1),
+			TestRunResultCount: int64(b.uniqifier + 1),
+			IsTestRunBlocked:   b.uniqifier%2 == 0,
+		}
+		results = append(results, result)
+	}
+	return results
+}
+
+// buildClusters returns the clusters that would be expected for this test
+// result, if current clustering algorithms were used.
+func (b *testResultBuilder) buildClusters(rules *cache.Ruleset, config *compiledcfg.ProjectConfig) []clustering.ClusterID {
+	var clusters []clustering.ClusterID
+	failure := &clustering.Failure{
+		TestID: b.testName,
+		Reason: b.failureReason,
+	}
+	testNameAlg := &testname.Algorithm{}
+	clusters = append(clusters, clustering.ClusterID{
+		Algorithm: testNameAlg.Name(),
+		ID:        hex.EncodeToString(testNameAlg.Cluster(config, failure)),
+	})
+	if b.failureReason != nil && b.failureReason.PrimaryErrorMessage != "" {
+		failureReasonAlg := &failurereason.Algorithm{}
+		clusters = append(clusters, clustering.ClusterID{
+			Algorithm: failureReasonAlg.Name(),
+			ID:        hex.EncodeToString(failureReasonAlg.Cluster(config, failure)),
+		})
+	}
+	vals := &clustering.Failure{
+		TestID: b.testName,
+		Reason: &pb.FailureReason{PrimaryErrorMessage: b.failureReason.GetPrimaryErrorMessage()},
+	}
+	for _, rule := range rules.ActiveRulesSorted {
+		if rule.Expr.Evaluate(vals) {
+			clusters = append(clusters, clustering.ClusterID{
+				Algorithm: rulesalgorithm.AlgorithmName,
+				ID:        rule.Rule.RuleID,
+			})
+		}
+	}
+	clustering.SortClusters(clusters)
+	return clusters
+}
+
+// chunkBuilder is used to build a chunk with test results, clustering state
+// and BigQuery exports, for testing.
+type chunkBuilder struct {
+	project       string
+	chunkID       string
+	objectID      string
+	testResults   []*testResultBuilder
+	ruleset       *cache.Ruleset
+	config        *compiledcfg.ProjectConfig
+	oldAlgorithms bool
+}
+
+// newChunk returns a new chunkBuilder for creating a new chunk. Uniqifier
+// is used to generate a chunk ID.
+func newChunk(uniqifier int) *chunkBuilder {
+	chunkID := sha256.Sum256([]byte(fmt.Sprintf("chunk-%v", uniqifier)))
+	objectID := sha256.Sum256([]byte(fmt.Sprintf("object-%v", uniqifier)))
+	config, err := compiledcfg.NewConfig(&configpb.ProjectConfig{
+		LastUpdated: timestamppb.New(time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)),
+	})
+	if err != nil {
+		// This should never occur, as the config should be valid.
+		panic(err)
+	}
+	return &chunkBuilder{
+		project:       "testproject",
+		chunkID:       hex.EncodeToString(chunkID[:16]),
+		objectID:      hex.EncodeToString(objectID[:16]),
+		ruleset:       cache.NewRuleset("", nil, rules.StartingVersion, time.Time{}),
+		config:        config,
+		oldAlgorithms: false,
+	}
+}
+
+func (b *chunkBuilder) withProject(project string) *chunkBuilder {
+	b.project = project
+	return b
+}
+
+func (b *chunkBuilder) withTestResults(tr ...*testResultBuilder) *chunkBuilder {
+	b.testResults = tr
+	return b
+}
+
+// withOldAlgorithms sets whether out of date algorithms
+// should be used instead of current clustering.
+func (b *chunkBuilder) withOldAlgorithms(old bool) *chunkBuilder {
+	b.oldAlgorithms = old
+	return b
+}
+
+// withRuleset sets the ruleset to use to determine current clustering
+// (only used if out-of-date algorithms is not set).
+func (b *chunkBuilder) withRuleset(ruleset *cache.Ruleset) *chunkBuilder {
+	b.ruleset = ruleset
+	return b
+}
+
+// withConfig sets the configuration to use to determine current clustering
+// (only used if out-of-date algorithms is not set).
+func (b *chunkBuilder) withConfig(config *compiledcfg.ProjectConfig) *chunkBuilder {
+	b.config = config
+	return b
+}
+
+func (b *chunkBuilder) buildTestResults() (chunk *cpb.Chunk) {
+	var failures []*cpb.Failure
+	for i, tr := range b.testResults {
+		failure := tr.buildFailure()
+		failure.ChunkIndex = int64(i + 1)
+		failures = append(failures, failure)
+	}
+	return &cpb.Chunk{
+		Failures: failures,
+	}
+}
+
+func (b *chunkBuilder) buildState() *state.Entry {
+	var crs clustering.ClusterResults
+	if b.oldAlgorithms {
+		algs := make(map[string]struct{})
+		algs["testname-v1"] = struct{}{}
+		algs["rules-v1"] = struct{}{}
+		var clusters [][]clustering.ClusterID
+		for range b.testResults {
+			cs := []clustering.ClusterID{
+				{
+					Algorithm: "testname-v1",
+					ID:        "01dc151e01dc151e01dc151e01dc151e",
+				},
+				{
+					Algorithm: "rules-v1",
+					ID:        "12341234123412341234123412341234",
+				},
+			}
+			clustering.SortClusters(cs)
+			clusters = append(clusters, cs)
+		}
+		crs = clustering.ClusterResults{
+			AlgorithmsVersion: 1,
+			ConfigVersion:     b.config.LastUpdated,
+			RulesVersion:      b.ruleset.Version.Predicates,
+			Algorithms:        algs,
+			Clusters:          clusters,
+		}
+	} else {
+		algs := make(map[string]struct{})
+		algs[testname.AlgorithmName] = struct{}{}
+		algs[failurereason.AlgorithmName] = struct{}{}
+		algs[rulesalgorithm.AlgorithmName] = struct{}{}
+		var clusters [][]clustering.ClusterID
+		for _, tr := range b.testResults {
+			clusters = append(clusters, tr.buildClusters(b.ruleset, b.config))
+		}
+		crs = clustering.ClusterResults{
+			AlgorithmsVersion: algorithms.AlgorithmsVersion,
+			ConfigVersion:     b.config.LastUpdated,
+			RulesVersion:      b.ruleset.Version.Predicates,
+			Algorithms:        algs,
+			Clusters:          clusters,
+		}
+	}
+
+	return &state.Entry{
+		Project:       b.project,
+		ChunkID:       b.chunkID,
+		PartitionTime: time.Date(2020, time.April, 1, 2, 3, 4, 0, time.UTC),
+		ObjectID:      b.objectID,
+		Clustering:    crs,
+	}
+}
+
+func (b *chunkBuilder) buildBQExport() []*bqpb.ClusteredFailureRow {
+	state := b.buildState()
+	var result []*bqpb.ClusteredFailureRow
+	for i, tr := range b.testResults {
+		cIDs := state.Clustering.Clusters[i]
+		rows := tr.buildBQExport(cIDs)
+		for _, r := range rows {
+			r.ChunkId = b.chunkID
+			r.ChunkIndex = int64(i + 1)
+		}
+		result = append(result, rows...)
+	}
+	return result
+}
+
+// scenarioBuilder is used to generate Weetbix system states used for testing.
+// Each scenario represents a consistent state of the Weetbix system, i.e.
+// - where the clustering state matches the configured rules, and
+// - the BigQuery exports match the clustering state, and the test results
+//   in the chunk store.
+type scenarioBuilder struct {
+	project       string
+	chunkCount    int
+	oldAlgorithms bool
+	oldRules      bool
+	oldConfig     bool
+}
+
+func newScenario() *scenarioBuilder {
+	return &scenarioBuilder{
+		project:    testProject,
+		chunkCount: 2,
+	}
+}
+
+func (b *scenarioBuilder) withOldAlgorithms(value bool) *scenarioBuilder {
+	b.oldAlgorithms = value
+	return b
+}
+
+func (b *scenarioBuilder) withOldRules(value bool) *scenarioBuilder {
+	b.oldRules = value
+	return b
+}
+
+func (b *scenarioBuilder) withOldConfig(value bool) *scenarioBuilder {
+	b.oldConfig = value
+	return b
+}
+
+func (b *scenarioBuilder) build() *scenario {
+	var rs []*rules.FailureAssociationRule
+	var activeRules []*cache.CachedRule
+
+	rulesVersion := rules.Version{
+		Predicates: time.Date(2001, time.January, 1, 0, 0, 0, 1000, time.UTC),
+		Total:      time.Date(2001, time.January, 1, 0, 0, 0, 2000, time.UTC),
+	}
+	ruleOne := rules.NewRule(0).WithProject(b.project).
+		WithRuleDefinition(`test = "test_b"`).
+		WithPredicateLastUpdated(rulesVersion.Predicates).
+		WithLastUpdated(rulesVersion.Total).
+		Build()
+	rs = []*rules.FailureAssociationRule{ruleOne}
+	if !b.oldRules {
+		rulesVersion = rules.Version{
+			Predicates: time.Date(2002, time.January, 1, 0, 0, 0, 1000, time.UTC),
+			Total:      time.Date(2002, time.January, 1, 0, 0, 0, 2000, time.UTC),
+		}
+		ruleTwo := rules.NewRule(1).WithProject(b.project).
+			WithRuleDefinition(`reason = "reason_b"`).
+			WithPredicateLastUpdated(rulesVersion.Predicates).
+			WithLastUpdated(rulesVersion.Total).
+			Build()
+		rs = append(rs, ruleTwo)
+	}
+	for _, r := range rs {
+		active, err := cache.NewCachedRule(r)
+		So(err, ShouldBeNil)
+		activeRules = append(activeRules, active)
+	}
+
+	configVersion := time.Date(2001, time.January, 2, 0, 0, 0, 1, time.UTC)
+	cfgpb := &configpb.Clustering{
+		TestNameRules: []*configpb.TestNameClusteringRule{
+			{
+				Name:         "Test underscore clustering",
+				Pattern:      `^(?P<name>\w+)_\w+$`,
+				LikeTemplate: `${name}%`,
+			},
+		},
+	}
+	if !b.oldConfig {
+		configVersion = time.Date(2002, time.January, 2, 0, 0, 0, 1, time.UTC)
+		cfgpb = &configpb.Clustering{
+			TestNameRules: []*configpb.TestNameClusteringRule{
+				{
+					Name:         "Test underscore clustering",
+					Pattern:      `^(?P<name>\w+)_\w+$`,
+					LikeTemplate: `${name}\_%`,
+				},
+			},
+		}
+	}
+
+	ruleset := cache.NewRuleset(b.project, activeRules, rulesVersion, time.Time{})
+	projectCfg := &configpb.ProjectConfig{
+		Clustering:  cfgpb,
+		LastUpdated: timestamppb.New(configVersion),
+	}
+	cfg, err := compiledcfg.NewConfig(projectCfg)
+	if err != nil {
+		// Should never occur as config should be valid.
+		panic(err)
+	}
+
+	var state []*state.Entry
+	testResultsByObjectID := make(map[string]*cpb.Chunk)
+	var bqExports []*bqpb.ClusteredFailureRow
+	for i := 0; i < b.chunkCount; i++ {
+		trOne := newTestResult(i * 2).withFailureReason(&pb.FailureReason{
+			PrimaryErrorMessage: "reason_a",
+		}).withTestName("test_a")
+		trTwo := newTestResult(i*2 + 1).withFailureReason(&pb.FailureReason{
+			PrimaryErrorMessage: "reason_b",
+		}).withTestName("test_b")
+
+		cb := newChunk(i).withProject(b.project).
+			withOldAlgorithms(b.oldAlgorithms).
+			withRuleset(ruleset).
+			withConfig(cfg).
+			withTestResults(trOne, trTwo)
+
+		s := cb.buildState()
+		state = append(state, s)
+		bqExports = append(bqExports, cb.buildBQExport()...)
+		testResultsByObjectID[s.ObjectID] = cb.buildTestResults()
+	}
+	sortState(state)
+	sortBQExport(bqExports)
+	return &scenario{
+		config:                cfgpb,
+		configVersion:         configVersion,
+		rulesVersion:          rulesVersion,
+		rules:                 rs,
+		testResultsByObjectID: testResultsByObjectID,
+		clusteringState:       state,
+		netBQExports:          bqExports,
+	}
+}
+
+// sortState sorts state.Entry elements in ascending ChunkID order.
+func sortState(state []*state.Entry) {
+	sort.Slice(state, func(i, j int) bool {
+		return state[i].ChunkID < state[j].ChunkID
+	})
+}
+
+// sortBQExport sorts BigQuery export rows in ascending key order.
+func sortBQExport(rows []*bqpb.ClusteredFailureRow) {
+	sort.Slice(rows, func(i, j int) bool {
+		return bigQueryKey(rows[i]) < bigQueryKey(rows[j])
+	})
+}
diff --git a/analysis/internal/clustering/rules/cache/cache.go b/analysis/internal/clustering/rules/cache/cache.go
new file mode 100644
index 0000000..ce6198b
--- /dev/null
+++ b/analysis/internal/clustering/rules/cache/cache.go
@@ -0,0 +1,125 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/data/caching/lru"
+	"go.chromium.org/luci/server/caching"
+)
+
+// refreshInterval controls how often rulesets are refreshed.
+const refreshInterval = time.Minute
+
+// StrongRead is a special time used to request the read of a ruleset
+// that contains all rule changes committed prior to the start of the
+// read. (Rule changes made after the start of the read may also
+// be returned.)
+// Under the covers, this results in a Spanner Strong Read.
+// See https://cloud.google.com/spanner/docs/reads for more.
+var StrongRead = time.Unix(0, 0).In(time.FixedZone("RuleCache StrongRead", 0xDB))
+
+// RulesCache is an in-process cache of failure association rules used
+// by LUCI projects.
+type RulesCache struct {
+	cache caching.LRUHandle
+}
+
+// NewRulesCache initialises a new RulesCache.
+func NewRulesCache(c caching.LRUHandle) *RulesCache {
+	return &RulesCache{
+		cache: c,
+	}
+}
+
+// Ruleset obtains the Ruleset for a particular project from the cache, or if
+// it does not exist, retrieves it from Spanner. MinimumPredicatesVersion
+// specifies the minimum version of rule predicates that must be incorporated
+// in the given Ruleset. If no particular version is desired, pass
+// rules.StartingEpoch. If a strong read is required, pass StrongRead.
+// Otherwise, pass the particular (minimum) version required.
+func (c *RulesCache) Ruleset(ctx context.Context, project string, minimumPredicatesVersion time.Time) (*Ruleset, error) {
+	var err error
+	readStart := clock.Now(ctx)
+
+	// Fast path: try and use the existing cached value (if any).
+	entry, ok := c.cache.LRU(ctx).Get(ctx, project)
+	if ok {
+		ruleset := entry.(*Ruleset)
+		if isRulesetUpToDate(ruleset, readStart, minimumPredicatesVersion) {
+			return ruleset, nil
+		}
+	}
+
+	// Update the cache. This requires acquiring the mutex that
+	// controls updates to the cache entry.
+	value, _ := c.cache.LRU(ctx).Mutate(ctx, project, func(it *lru.Item) *lru.Item {
+		// Only one goroutine will enter this section at one time.
+		var ruleset *Ruleset
+		if it != nil {
+			ruleset = it.Value.(*Ruleset)
+			if isRulesetUpToDate(ruleset, readStart, minimumPredicatesVersion) {
+				// The ruleset is up-to-date. Do not mutate it further.
+				// This can happen if the ruleset updated while we were
+				// waiting to acquire the mutex to update the cache entry.
+				return it
+			}
+		} else {
+			ruleset = newEmptyRuleset(project)
+		}
+		ruleset, err = ruleset.refresh(ctx)
+		if err != nil {
+			// Issue refreshing ruleset. Keep the cached value (if any) for now.
+			return it
+		}
+		return &lru.Item{
+			Value: ruleset,
+			Exp:   0, // Never.
+		}
+	})
+	if err != nil {
+		return nil, err
+	}
+	ruleset := value.(*Ruleset)
+	if minimumPredicatesVersion != StrongRead && ruleset.Version.Predicates.Before(minimumPredicatesVersion) {
+		return nil, fmt.Errorf("could not obtain ruleset of requested minimum predicate version (%v)", minimumPredicatesVersion)
+	}
+	return ruleset, nil
+}
+
+func isRulesetUpToDate(rs *Ruleset, readStart, minimumPredicatesVersion time.Time) bool {
+	if minimumPredicatesVersion == StrongRead {
+		if rs.LastRefresh.After(readStart) {
+			// We deliberately use a cached ruleset for some strong
+			// reads so long as the refresh occurred after the call to
+			// Ruleset(...).
+			// This is to ensure that even if Ruleset(...) receives
+			// many requests for StrongReads, each will at most need
+			// to wait for the next strong read to complete, rather
+			// than being bottlenecked by the fact only one goroutine
+			// can enter the section to update the cache entry at once.
+			return true
+		}
+	} else {
+		if rs.LastRefresh.Add(refreshInterval).After(readStart) && !rs.Version.Predicates.Before(minimumPredicatesVersion) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/analysis/internal/clustering/rules/cache/cache_test.go b/analysis/internal/clustering/rules/cache/cache_test.go
new file mode 100644
index 0000000..5de8326
--- /dev/null
+++ b/analysis/internal/clustering/rules/cache/cache_test.go
@@ -0,0 +1,279 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+	"sort"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	"go.chromium.org/luci/server/caching"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+var cache = caching.RegisterLRUCache(50)
+
+func TestRulesCache(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx, tc := testclock.UseTime(ctx, testclock.TestRecentTimeUTC)
+		ctx = caching.WithEmptyProcessCache(ctx)
+
+		rc := NewRulesCache(cache)
+		rules.SetRulesForTesting(ctx, nil)
+
+		test := func(minimumPredicatesVerison time.Time, expectedRules []*rules.FailureAssociationRule, expectedVersion rules.Version) {
+			// Tests the content of the cache is as expected.
+			ruleset, err := rc.Ruleset(ctx, "myproject", minimumPredicatesVerison)
+			So(err, ShouldBeNil)
+			So(ruleset.Version, ShouldResemble, expectedVersion)
+
+			activeRules := 0
+			for _, e := range expectedRules {
+				if e.IsActive {
+					activeRules++
+				}
+			}
+			So(len(ruleset.ActiveRulesSorted), ShouldEqual, activeRules)
+			So(len(ruleset.ActiveRulesByID), ShouldEqual, activeRules)
+
+			sortedExpectedRules := sortRulesByPredicateLastUpdated(expectedRules)
+
+			actualRuleIndex := 0
+			for _, e := range sortedExpectedRules {
+				if e.IsActive {
+					a := ruleset.ActiveRulesSorted[actualRuleIndex]
+					So(a.Rule, ShouldResemble, *e)
+					// Technically (*lang.Expr).String() may not get us
+					// back the original rule if RuleDefinition didn't use
+					// normalised formatting. But for this test, we use
+					// normalised formatting, so that is not an issue.
+					So(a.Expr, ShouldNotBeNil)
+					So(a.Expr.String(), ShouldEqual, e.RuleDefinition)
+					actualRuleIndex++
+
+					a2, ok := ruleset.ActiveRulesByID[a.Rule.RuleID]
+					So(ok, ShouldBeTrue)
+					So(a2.Rule, ShouldResemble, *e)
+				}
+			}
+			So(len(ruleset.ActiveRulesWithPredicateUpdatedSince(rules.StartingEpoch)), ShouldEqual, activeRules)
+			So(len(ruleset.ActiveRulesWithPredicateUpdatedSince(time.Date(2100, time.January, 1, 1, 0, 0, 0, time.UTC))), ShouldEqual, 0)
+		}
+
+		Convey(`Initially Empty`, func() {
+			err := rules.SetRulesForTesting(ctx, nil)
+			So(err, ShouldBeNil)
+			test(rules.StartingEpoch, nil, rules.StartingVersion)
+
+			Convey(`Then Empty`, func() {
+				// Test cache.
+				test(rules.StartingEpoch, nil, rules.StartingVersion)
+
+				tc.Add(refreshInterval)
+
+				test(rules.StartingEpoch, nil, rules.StartingVersion)
+				test(rules.StartingEpoch, nil, rules.StartingVersion)
+			})
+			Convey(`Then Non-Empty`, func() {
+				// Spanner commit timestamps are in microsecond
+				// (not nanosecond) granularity, and some Spanner timestamp
+				// operators truncates to microseconds. For this
+				// reason, we use microsecond resolution timestamps
+				// when testing.
+				reference := time.Date(2020, 1, 2, 3, 4, 5, 6000, time.UTC)
+
+				rs := []*rules.FailureAssociationRule{
+					rules.NewRule(100).
+						WithLastUpdated(reference.Add(-1 * time.Hour)).
+						WithPredicateLastUpdated(reference.Add(-2 * time.Hour)).
+						Build(),
+					rules.NewRule(101).WithActive(false).
+						WithLastUpdated(reference.Add(1 * time.Hour)).
+						WithPredicateLastUpdated(reference).
+						Build(),
+				}
+				err := rules.SetRulesForTesting(ctx, rs)
+				So(err, ShouldBeNil)
+
+				expectedRulesVersion := rules.Version{
+					Total:      reference.Add(1 * time.Hour),
+					Predicates: reference,
+				}
+
+				Convey(`By Strong Read`, func() {
+					test(StrongRead, rs, expectedRulesVersion)
+					test(StrongRead, rs, expectedRulesVersion)
+				})
+				Convey(`By Requesting Version`, func() {
+					test(expectedRulesVersion.Predicates, rs, expectedRulesVersion)
+				})
+				Convey(`By Cache Expiry`, func() {
+					// Test cache is working and still returning the old value.
+					tc.Add(refreshInterval / 2)
+					test(rules.StartingEpoch, nil, rules.StartingVersion)
+
+					tc.Add(refreshInterval)
+
+					test(rules.StartingEpoch, rs, expectedRulesVersion)
+					test(rules.StartingEpoch, rs, expectedRulesVersion)
+				})
+			})
+		})
+		Convey(`Initially Non-Empty`, func() {
+			reference := time.Date(2021, 1, 2, 3, 4, 5, 6000, time.UTC)
+
+			ruleOne := rules.NewRule(100).
+				WithLastUpdated(reference.Add(-2 * time.Hour)).
+				WithPredicateLastUpdated(reference.Add(-3 * time.Hour))
+			ruleTwo := rules.NewRule(101).
+				WithLastUpdated(reference.Add(-2 * time.Hour)).
+				WithPredicateLastUpdated(reference.Add(-3 * time.Hour))
+			ruleThree := rules.NewRule(102).WithActive(false).
+				WithLastUpdated(reference).
+				WithPredicateLastUpdated(reference.Add(-1 * time.Hour))
+
+			rs := []*rules.FailureAssociationRule{
+				ruleOne.Build(),
+				ruleTwo.Build(),
+				ruleThree.Build(),
+			}
+			err := rules.SetRulesForTesting(ctx, rs)
+			So(err, ShouldBeNil)
+
+			expectedRulesVersion := rules.Version{
+				Total:      reference,
+				Predicates: reference.Add(-1 * time.Hour),
+			}
+			test(rules.StartingEpoch, rs, expectedRulesVersion)
+
+			Convey(`Then Empty`, func() {
+				// Mark all rules inactive.
+				newRules := []*rules.FailureAssociationRule{
+					ruleOne.WithActive(false).
+						WithLastUpdated(reference.Add(4 * time.Hour)).
+						WithPredicateLastUpdated(reference.Add(3 * time.Hour)).
+						Build(),
+					ruleTwo.WithActive(false).
+						WithLastUpdated(reference.Add(2 * time.Hour)).
+						WithPredicateLastUpdated(reference.Add(1 * time.Hour)).
+						Build(),
+					ruleThree.WithActive(false).
+						WithLastUpdated(reference.Add(2 * time.Hour)).
+						WithPredicateLastUpdated(reference.Add(1 * time.Hour)).
+						Build(),
+				}
+				err := rules.SetRulesForTesting(ctx, newRules)
+				So(err, ShouldBeNil)
+
+				oldRulesVersion := expectedRulesVersion
+				expectedRulesVersion := rules.Version{
+					Total:      reference.Add(4 * time.Hour),
+					Predicates: reference.Add(3 * time.Hour),
+				}
+
+				Convey(`By Strong Read`, func() {
+					test(StrongRead, newRules, expectedRulesVersion)
+					test(StrongRead, newRules, expectedRulesVersion)
+				})
+				Convey(`By Requesting Version`, func() {
+					test(expectedRulesVersion.Predicates, newRules, expectedRulesVersion)
+				})
+				Convey(`By Cache Expiry`, func() {
+					// Test cache is working and still returning the old value.
+					tc.Add(refreshInterval / 2)
+					test(rules.StartingEpoch, rs, oldRulesVersion)
+
+					tc.Add(refreshInterval)
+
+					test(rules.StartingEpoch, newRules, expectedRulesVersion)
+					test(rules.StartingEpoch, newRules, expectedRulesVersion)
+				})
+			})
+			Convey(`Then Non-Empty`, func() {
+				newRules := []*rules.FailureAssociationRule{
+					// Mark an existing rule inactive.
+					ruleOne.WithActive(false).
+						WithLastUpdated(reference.Add(time.Hour)).
+						WithPredicateLastUpdated(reference.Add(time.Hour)).
+						Build(),
+					// Make a non-predicate change on an active rule.
+					ruleTwo.
+						WithBug(bugs.BugID{System: "monorail", ID: "project/123"}).
+						WithLastUpdated(reference.Add(time.Hour)).
+						Build(),
+					// Make an existing rule active.
+					ruleThree.WithActive(true).
+						WithLastUpdated(reference.Add(time.Hour)).
+						WithPredicateLastUpdated(reference.Add(time.Hour)).
+						Build(),
+					// Add a new active rule.
+					rules.NewRule(103).
+						WithPredicateLastUpdated(reference.Add(time.Hour)).
+						WithLastUpdated(reference.Add(time.Hour)).
+						Build(),
+					// Add a new inactive rule.
+					rules.NewRule(104).WithActive(false).
+						WithPredicateLastUpdated(reference.Add(2 * time.Hour)).
+						WithLastUpdated(reference.Add(3 * time.Hour)).
+						Build(),
+				}
+				err := rules.SetRulesForTesting(ctx, newRules)
+				So(err, ShouldBeNil)
+
+				oldRulesVersion := expectedRulesVersion
+				expectedRulesVersion := rules.Version{
+					Total:      reference.Add(3 * time.Hour),
+					Predicates: reference.Add(2 * time.Hour),
+				}
+
+				Convey(`By Strong Read`, func() {
+					test(StrongRead, newRules, expectedRulesVersion)
+					test(StrongRead, newRules, expectedRulesVersion)
+				})
+				Convey(`By Forced Eviction`, func() {
+					test(expectedRulesVersion.Predicates, newRules, expectedRulesVersion)
+				})
+				Convey(`By Cache Expiry`, func() {
+					// Test cache is working and still returning the old value.
+					tc.Add(refreshInterval / 2)
+					test(rules.StartingEpoch, rs, oldRulesVersion)
+
+					tc.Add(refreshInterval)
+
+					test(rules.StartingEpoch, newRules, expectedRulesVersion)
+					test(rules.StartingEpoch, newRules, expectedRulesVersion)
+				})
+			})
+		})
+	})
+}
+
+func sortRulesByPredicateLastUpdated(rs []*rules.FailureAssociationRule) []*rules.FailureAssociationRule {
+	result := make([]*rules.FailureAssociationRule, len(rs))
+	copy(result, rs)
+	sort.Slice(result, func(i, j int) bool {
+		if result[i].PredicateLastUpdated.Equal(result[j].PredicateLastUpdated) {
+			return result[i].RuleID < result[j].RuleID
+		}
+		return result[i].PredicateLastUpdated.After(result[j].PredicateLastUpdated)
+	})
+	return result
+}
diff --git a/analysis/internal/clustering/rules/cache/main_test.go b/analysis/internal/clustering/rules/cache/main_test.go
new file mode 100644
index 0000000..5ad28b0
--- /dev/null
+++ b/analysis/internal/clustering/rules/cache/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/rules/cache/ruleset.go b/analysis/internal/clustering/rules/cache/ruleset.go
new file mode 100644
index 0000000..dd076b9
--- /dev/null
+++ b/analysis/internal/clustering/rules/cache/ruleset.go
@@ -0,0 +1,246 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cache
+
+import (
+	"context"
+	"sort"
+	"time"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/trace"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
+)
+
+// CachedRule represents a "compiled" version of a failure
+// association rule.
+// It should be treated as immutable, and is therefore safe to
+// share across multiple threads.
+type CachedRule struct {
+	// The failure association rule.
+	Rule rules.FailureAssociationRule
+	// The parsed and compiled failure association rule.
+	Expr *lang.Expr
+}
+
+// NewCachedRule initialises a new CachedRule from the given failure
+// association rule.
+func NewCachedRule(rule *rules.FailureAssociationRule) (*CachedRule, error) {
+	expr, err := lang.Parse(rule.RuleDefinition)
+	if err != nil {
+		return nil, err
+	}
+	return &CachedRule{
+		Rule: *rule,
+		Expr: expr,
+	}, nil
+}
+
+// Ruleset represents a version of the set of failure
+// association rules in use by a LUCI Project.
+// It should be treated as immutable, and therefore safe to share
+// across multiple threads.
+type Ruleset struct {
+	// The LUCI Project.
+	Project string
+	// ActiveRulesSorted is the set of active failure association rules
+	// (should be used by Weetbix for matching), sorted in descending
+	// PredicateLastUpdated time order.
+	ActiveRulesSorted []*CachedRule
+	// ActiveRulesByID stores active failure association
+	// rules by their Rule ID.
+	ActiveRulesByID map[string]*CachedRule
+	// Version versions the contents of the Ruleset. These timestamps only
+	// change if a rule is modified.
+	Version rules.Version
+	// LastRefresh contains the monotonic clock reading when the last ruleset
+	// refresh was initiated. The refresh is guaranteed to contain all rules
+	// changes made prior to this timestamp.
+	LastRefresh time.Time
+}
+
+// ActiveRulesWithPredicateUpdatedSince returns the set of rules that are
+// active and whose predicates have been updated since (but not including)
+// the given time.
+// Rules which have been made inactive since the given time will NOT be
+// returned. To check if a previous rule has been made inactive, consider
+// using IsRuleActive instead.
+// The returned slice must not be mutated.
+func (r *Ruleset) ActiveRulesWithPredicateUpdatedSince(t time.Time) []*CachedRule {
+	// Use the property that ActiveRules is sorted by descending
+	// LastUpdated time.
+	for i, rule := range r.ActiveRulesSorted {
+		if !rule.Rule.PredicateLastUpdated.After(t) {
+			// This is the first rule that has not been updated since time t.
+			// Return all rules up to (but not including) this rule.
+			return r.ActiveRulesSorted[:i]
+		}
+	}
+	return r.ActiveRulesSorted
+}
+
+// Returns whether the given ruleID is an active rule.
+func (r *Ruleset) IsRuleActive(ruleID string) bool {
+	_, ok := r.ActiveRulesByID[ruleID]
+	return ok
+}
+
+// newEmptyRuleset initialises a new empty ruleset.
+// This initial ruleset is invalid and must be refreshed before use.
+func newEmptyRuleset(project string) *Ruleset {
+	return &Ruleset{
+		Project:           project,
+		ActiveRulesSorted: nil,
+		ActiveRulesByID:   make(map[string]*CachedRule),
+		// The zero predicate last updated time is not valid and will be
+		// rejected by clustering state validation if we ever try to save
+		// it to Spanner as a chunk's RulesVersion.
+		Version:     rules.Version{},
+		LastRefresh: time.Time{},
+	}
+}
+
+// NewRuleset creates a new ruleset with the given project,
+// active rules, rules last updated and last refresh time.
+func NewRuleset(project string, activeRules []*CachedRule, version rules.Version, lastRefresh time.Time) *Ruleset {
+	return &Ruleset{
+		Project:           project,
+		ActiveRulesSorted: sortByDescendingPredicateLastUpdated(activeRules),
+		ActiveRulesByID:   rulesByID(activeRules),
+		Version:           version,
+		LastRefresh:       lastRefresh,
+	}
+}
+
+// refresh updates the ruleset. To ensure existing users of the rulset
+// do not observe changes while they are using it, a new copy is returned.
+func (r *Ruleset) refresh(ctx context.Context) (ruleset *Ruleset, err error) {
+	// Under our design assumption of 10,000 active rules per project,
+	// pulling and compiling all rules could take a meaningful amount
+	// of time (@ 1KB per rule, = ~10MB).
+	ctx, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/clustering/rules/cache.Refresh")
+	s.Attribute("project", r.Project)
+	defer func() { s.End(err) }()
+
+	// Use clock reading before refresh. The refresh is guaranteed
+	// to contain all rule changes committed to Spanner prior to
+	// this timestamp.
+	lastRefresh := clock.Now(ctx)
+
+	txn, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+
+	var activeRules []*CachedRule
+	if r.Version == (rules.Version{}) {
+		// On the first refresh, query all active rules.
+		ruleRows, err := rules.ReadActive(txn, r.Project)
+		if err != nil {
+			return nil, err
+		}
+		activeRules, err = cachedRulesFromFullRead(ruleRows)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		// On subsequent refreshes, query just the differences.
+		delta, err := rules.ReadDelta(txn, r.Project, r.Version.Total)
+		if err != nil {
+			return nil, err
+		}
+		activeRules, err = cachedRulesFromDelta(r.ActiveRulesSorted, delta)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// Get the version of set of rules read by ReadActive/ReadDelta.
+	// Must occur in the same spanner transaction as ReadActive/ReadDelta.
+	// If the project has no rules, this returns rules.StartingEpoch.
+	rulesVersion, err := rules.ReadVersion(txn, r.Project)
+	if err != nil {
+		return nil, err
+	}
+
+	return NewRuleset(r.Project, activeRules, rulesVersion, lastRefresh), nil
+}
+
+// cachedRulesFromFullRead obtains a set of cached rules from the given set of
+// active failure association rules.
+func cachedRulesFromFullRead(activeRules []*rules.FailureAssociationRule) ([]*CachedRule, error) {
+	var result []*CachedRule
+	for _, r := range activeRules {
+		cr, err := NewCachedRule(r)
+		if err != nil {
+			return nil, errors.Annotate(err, "rule %s is invalid", r.RuleID).Err()
+		}
+		result = append(result, cr)
+	}
+	return result, nil
+}
+
+// cachedRulesFromDelta applies deltas to an existing list of rules,
+// to obtain an updated set of rules.
+func cachedRulesFromDelta(existing []*CachedRule, delta []*rules.FailureAssociationRule) ([]*CachedRule, error) {
+	ruleByID := make(map[string]*CachedRule)
+	for _, r := range existing {
+		ruleByID[r.Rule.RuleID] = r
+	}
+	for _, d := range delta {
+		if d.IsActive {
+			cr, err := NewCachedRule(d)
+			if err != nil {
+				return nil, errors.Annotate(err, "rule %s is invalid", d.RuleID).Err()
+			}
+			ruleByID[d.RuleID] = cr
+		} else {
+			// Delete the rule, if it exists.
+			delete(ruleByID, d.RuleID)
+		}
+	}
+	var results []*CachedRule
+	for _, r := range ruleByID {
+		results = append(results, r)
+	}
+	return results, nil
+}
+
+// sortByDescendingPredicateLastUpdated sorts the given rules in descending
+// predicate last-updated time order. If two rules have the same
+// PredicateLastUpdated time, they are sorted in RuleID order.
+func sortByDescendingPredicateLastUpdated(rules []*CachedRule) []*CachedRule {
+	result := make([]*CachedRule, len(rules))
+	copy(result, rules)
+	sort.Slice(result, func(i, j int) bool {
+		if result[i].Rule.PredicateLastUpdated.Equal(result[j].Rule.PredicateLastUpdated) {
+			return result[i].Rule.RuleID < result[j].Rule.RuleID
+		}
+		return result[i].Rule.PredicateLastUpdated.After(result[j].Rule.PredicateLastUpdated)
+	})
+	return result
+}
+
+// rulesByID creates a mapping from rule ID to rules for the given list
+// of failure association rules.
+func rulesByID(rules []*CachedRule) map[string]*CachedRule {
+	result := make(map[string]*CachedRule)
+	for _, r := range rules {
+		result[r.Rule.RuleID] = r
+	}
+	return result
+}
diff --git a/analysis/internal/clustering/rules/lang/escape.go b/analysis/internal/clustering/rules/lang/escape.go
new file mode 100644
index 0000000..f9aef9a
--- /dev/null
+++ b/analysis/internal/clustering/rules/lang/escape.go
@@ -0,0 +1,126 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lang
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+	"strings"
+	"unicode/utf8"
+)
+
+// regexpMetacharacters is the set of characters that have meaning (beyond
+// the literal value) to the RE2 regular expression engine.
+var regexpMetacharacters map[rune]struct{}
+
+func init() {
+	regexpMetacharacters = make(map[rune]struct{})
+	for _, r := range `\.+*?()|[]{}^$` {
+		regexpMetacharacters[r] = struct{}{}
+	}
+}
+
+// likePatternToRegexp converts the given LIKE pattern to a corresponding
+// RE2 regular expression pattern. The "%" and "_" tokens are encoded as
+// ".*" and "." in the corresponding regex, unless they are escaped with
+// a backslash "\" . Any regexp metacharacters in the input string
+// are escaped to ensure they are not interpreted.
+func likePatternToRegexp(likePattern string) (string, error) {
+	var b strings.Builder
+	// Set flags to let . match any character, including "\n".
+	b.WriteString("(?s)")
+	// Match start of string.
+	b.WriteString("^")
+	isEscaping := false
+	for _, r := range likePattern {
+		switch {
+		case !isEscaping && r == '\\':
+			isEscaping = true
+		case !isEscaping && r == '%':
+			b.WriteString(".*")
+		case !isEscaping && r == '_':
+			b.WriteString(".")
+		case isEscaping && (r != '\\' && r != '%' && r != '_'):
+			return "", fmt.Errorf(`unrecognised escape sequence in LIKE pattern "\%s"`, string(r))
+		default: // !isEscaping || (isEscaping && (r == '\\' || r == '%' || r == '_'))
+			// Match the literal character.
+			if _, ok := regexpMetacharacters[r]; ok {
+				// Escape regex metacharacters with a '\'.
+				b.WriteRune('\\')
+				b.WriteRune(r)
+			} else {
+				b.WriteRune(r)
+			}
+			isEscaping = false
+		}
+	}
+	if isEscaping {
+		return "", errors.New(`unfinished escape sequence "\" at end of LIKE pattern`)
+	}
+	// Match end of string.
+	b.WriteString("$")
+	return b.String(), nil
+}
+
+// ValidateLikePattern validates the given string is a valid LIKE
+// pattern. In particular, this checks that all escape sequences
+// are valid, and that there is no unfinished trailing escape
+// sequence (trailing '\').
+func ValidateLikePattern(likePattern string) error {
+	_, err := likePatternToRegexp(likePattern)
+	return err
+}
+
+// Matches double-quoted string literals supported by golang, which
+// are a subset of those supported by Standard SQL. Handles standard escape
+// sequences (\r, \n, etc.), plus octal, hex and unicode sequences.
+// Refer to:
+// https://golang.org/ref/spec#Rune_literals
+// https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical
+// Single-quoted string literals are currently not supported.
+const stringLiteralPattern = `"([^\\"]|\\[abfnrtv\\"]|\\[0-7]{3}|\\x[0-9a-fA-F]{2}|\\u[0-9a-fA-F]{4}|\\U[0-9a-fA-F]{8})*"`
+
+// unescapeStringLiteral derives the unescaped string value from an escaped
+// SQL string literal.
+func unescapeStringLiteral(s string) (string, error) {
+	// Interpret the string as a double-quoted go string
+	// literal, decoding any escape sequences. Except for '\?' and
+	// '\`', which are not supported in golang (but are not needed for
+	// expressiveness), this matches the escape sequences in Standard SQL.
+	// Refer to:
+	// https://golang.org/ref/spec#Rune_literals
+	// https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical
+	// In case of an attempt to encode Unicode surrogate values D800-DFFF,
+	// which are illegal in UTF-8 and Standard SQL, strconv inserts
+	// utf8.RuneError (aka "Unicode replacement character").
+	value, err := strconv.Unquote(s)
+	if err != nil {
+		// In most cases invalid strings should have already been
+		// rejected by the lexer.
+		return "", fmt.Errorf("invalid string literal: %s", s)
+	}
+	for _, r := range value {
+		if r == utf8.RuneError {
+			return "", fmt.Errorf("string literal contains invalid unicode code point: %s", s)
+		}
+	}
+	if !utf8.ValidString(value) {
+		// Check string is UTF-8.
+		// https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types#string_type
+		return "", fmt.Errorf("string literal is not valid UTF-8: %q", s)
+	}
+	return value, nil
+}
diff --git a/analysis/internal/clustering/rules/lang/lang.go b/analysis/internal/clustering/rules/lang/lang.go
new file mode 100644
index 0000000..6377037
--- /dev/null
+++ b/analysis/internal/clustering/rules/lang/lang.go
@@ -0,0 +1,574 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package lang parses failure association rule predicates. The predicate
+// syntax defined here is intended to be a subset of BigQuery Standard SQL's
+// Expression syntax, with the same semantics. This provides a few benefits:
+// - Well-known and understood syntax and semantics.
+// - Ability to leverage existing high-quality documentation to communicate
+//   language concepts to end-users.
+// - Simplified debugging of Weetbix (by allowing direct copy- paste of
+//   expressions into BigQuery to verify clustering is correct).
+// - Possibility of using BigQuery as an execution engine in future.
+//
+// Rules permitted by this package look similar to:
+//  reason LIKE "% exited with code 5 %" AND NOT
+//    ( test = "arc.Boot" OR test = "arc.StartStop" )
+//
+// The grammar for the language in Extended Backus-Naur form follows. The
+// top-level production rule is BoolExpr.
+//
+// BoolExpr = BoolTerm , ( "OR" , BoolTerm )* ;
+// BoolTerm = BoolFactor , ( "AND" , BoolFactor )* ;
+// BoolFactor = [ "NOT" ] BoolPrimary ;
+// BoolPrimary = BoolItem | BoolPredicate ;
+// BoolItem = BoolConst | "(" , BoolExpr , ")" | BoolFunc ;
+// BoolConst = "TRUE" | "FALSE" ;
+// BoolFunc = Identifier , "(" , StringExpr , ( "," , StringExpr )* , ")" ;
+// BoolPredicate = StringExpr , BoolTest ;
+// BoolTest = CompPredicate | NegatablePredicate ;
+// CompPredicate = Operator , StringExpr ;
+// Operator = "!=" | "<>" | "="
+// NegatablePredicate = [ "NOT" ] , ( InPredicate | LikePredicate ) ;
+// InPredicate = "IN" , "(" , StringExpr , ( "," , StringExpr )* , ")" ;
+// LikePredicate = "LIKE" , String ;
+// StringExpr = String | Identifier ;
+//
+// Where:
+// - Identifier represents the production rule for identifiers.
+// - String is the production rule for a double-quoted string literal.
+// The precise definitions of which are omitted here but found in the
+// implementation.
+package lang
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"regexp"
+	"strings"
+
+	participle "github.com/alecthomas/participle/v2"
+	"github.com/alecthomas/participle/v2/lexer"
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+)
+
+type validator struct {
+	errors []error
+}
+
+func newValidator() *validator {
+	return &validator{}
+}
+
+// ReportError reports a validation error.
+func (v *validator) reportError(err error) {
+	v.errors = append(v.errors, err)
+}
+
+// Error returns all validation errors that were encountered.
+func (v *validator) error() error {
+	if len(v.errors) > 0 {
+		return errors.NewMultiError(v.errors...)
+	}
+	return nil
+}
+
+type failure *clustering.Failure
+type boolEval func(failure) bool
+type stringEval func(failure) string
+type predicateEval func(failure, string) bool
+
+// Expr represents a predicate for a failure association rule.
+type Expr struct {
+	expr *boolExpr
+	eval boolEval
+}
+
+// String returns the predicate as a string, with normalised formatting.
+func (e *Expr) String() string {
+	var buf bytes.Buffer
+	e.expr.format(&buf)
+	return buf.String()
+}
+
+// Evaluate evaluates the given expression, using the given values
+// for variables used in the expression.
+func (e *Expr) Evaluate(failure *clustering.Failure) bool {
+	return e.eval(failure)
+}
+
+type boolExpr struct {
+	Terms []*boolTerm `parser:"@@ ( 'OR' @@ )*"`
+}
+
+func (e *boolExpr) format(w io.Writer) {
+	for i, t := range e.Terms {
+		if i > 0 {
+			io.WriteString(w, " OR ")
+		}
+		t.format(w)
+	}
+}
+
+func (e *boolExpr) evaluator(v *validator) boolEval {
+	var termEvals []boolEval
+	for _, t := range e.Terms {
+		termEvals = append(termEvals, t.evaluator(v))
+	}
+	if len(termEvals) == 1 {
+		return termEvals[0]
+	}
+	return func(f failure) bool {
+		for _, termEval := range termEvals {
+			if termEval(f) {
+				return true
+			}
+		}
+		return false
+	}
+}
+
+type boolTerm struct {
+	Factors []*boolFactor `parser:"@@ ( 'AND' @@ )*"`
+}
+
+func (t *boolTerm) format(w io.Writer) {
+	for i, f := range t.Factors {
+		if i > 0 {
+			io.WriteString(w, " AND ")
+		}
+		f.format(w)
+	}
+}
+
+func (t *boolTerm) evaluator(v *validator) boolEval {
+	var factorEvals []boolEval
+	for _, f := range t.Factors {
+		factorEvals = append(factorEvals, f.evaluator(v))
+	}
+	if len(factorEvals) == 1 {
+		return factorEvals[0]
+	}
+	return func(f failure) bool {
+		for _, factorEval := range factorEvals {
+			if !factorEval(f) {
+				return false
+			}
+		}
+		return true
+	}
+}
+
+type boolFactor struct {
+	Not     bool         `parser:"( @'NOT' )?"`
+	Primary *boolPrimary `parser:"@@"`
+}
+
+func (f *boolFactor) format(w io.Writer) {
+	if f.Not {
+		io.WriteString(w, "NOT ")
+	}
+	f.Primary.format(w)
+}
+
+func (f *boolFactor) evaluator(v *validator) boolEval {
+	predicate := f.Primary.evaluator(v)
+	if f.Not {
+		return func(f failure) bool {
+			return !predicate(f)
+		}
+	}
+	return predicate
+}
+
+type boolPrimary struct {
+	Item *boolItem      `parser:"@@"`
+	Test *boolPredicate `parser:"| @@"`
+}
+
+func (p *boolPrimary) format(w io.Writer) {
+	if p.Item != nil {
+		p.Item.format(w)
+	}
+	if p.Test != nil {
+		p.Test.format(w)
+	}
+}
+
+func (p *boolPrimary) evaluator(v *validator) boolEval {
+	if p.Item != nil {
+		return p.Item.evaluator(v)
+	}
+	return p.Test.evaluator(v)
+}
+
+type boolItem struct {
+	Const *boolConst    `parser:"@@"`
+	Expr  *boolExpr     `parser:"| '(' @@ ')'"`
+	Func  *boolFunction `parser:"| @@"`
+}
+
+func (i *boolItem) format(w io.Writer) {
+	if i.Const != nil {
+		i.Const.format(w)
+	}
+	if i.Expr != nil {
+		io.WriteString(w, "(")
+		i.Expr.format(w)
+		io.WriteString(w, ")")
+	}
+	if i.Func != nil {
+		i.Func.format(w)
+	}
+}
+
+func (p *boolItem) evaluator(v *validator) boolEval {
+	if p.Const != nil {
+		return p.Const.evaluator(v)
+	}
+	if p.Expr != nil {
+		return p.Expr.evaluator(v)
+	}
+	if p.Func != nil {
+		return p.Func.evaluator(v)
+	}
+	return nil
+}
+
+type boolConst struct {
+	Value string `parser:"@( 'TRUE' | 'FALSE' )"`
+}
+
+func (c *boolConst) format(w io.Writer) {
+	io.WriteString(w, c.Value)
+}
+
+func (c *boolConst) evaluator(v *validator) boolEval {
+	value := c.Value == "TRUE"
+	return func(f failure) bool {
+		return value
+	}
+}
+
+type boolFunction struct {
+	Function string        `parser:"@Ident"`
+	Args     []*stringExpr `parser:"'(' @@ ( ',' @@ )* ')'"`
+}
+
+func (f *boolFunction) format(w io.Writer) {
+	io.WriteString(w, f.Function)
+	io.WriteString(w, "(")
+	for i, arg := range f.Args {
+		if i > 0 {
+			io.WriteString(w, ", ")
+		}
+		arg.format(w)
+	}
+	io.WriteString(w, ")")
+}
+
+func (f *boolFunction) evaluator(v *validator) boolEval {
+	switch strings.ToLower(f.Function) {
+	case "regexp_contains":
+		if len(f.Args) != 2 {
+			v.reportError(fmt.Errorf("invalid number of arguments to REGEXP_CONTAINS: got %v, want 2", len(f.Args)))
+			return nil
+		}
+		valueEval := f.Args[0].evaluator(v)
+		pattern, ok := f.Args[1].asConstant(v)
+		if !ok {
+			// For efficiency reasons, we require the second argument to be a
+			// constant so that we can pre-compile the regular expression.
+			v.reportError(fmt.Errorf("expected second argument to REGEXP_CONTAINS to be a constant pattern"))
+			return nil
+		}
+		re, err := regexp.Compile(pattern)
+		if err != nil {
+			v.reportError(fmt.Errorf("invalid regular expression %q", pattern))
+			return nil
+		}
+
+		return func(f failure) bool {
+			value := valueEval(f)
+			return re.MatchString(value)
+		}
+	default:
+		v.reportError(fmt.Errorf("undefined function: %q", f.Function))
+		return nil
+	}
+}
+
+type boolPredicate struct {
+	Value *stringExpr `parser:"@@"`
+	Test  *boolTest   `parser:"@@"`
+}
+
+func (t *boolPredicate) format(w io.Writer) {
+	t.Value.format(w)
+	t.Test.format(w)
+}
+
+func (t *boolPredicate) evaluator(v *validator) boolEval {
+	value := t.Value.evaluator(v)
+	test := t.Test.evaluator(v)
+	return func(f failure) bool {
+		return test(f, value(f))
+	}
+}
+
+type boolTest struct {
+	Comp      *compPredicate      `parser:"@@"`
+	Negatable *negatablePredicate `parser:"| @@"`
+}
+
+func (t *boolTest) format(w io.Writer) {
+	if t.Comp != nil {
+		t.Comp.format(w)
+	}
+	if t.Negatable != nil {
+		t.Negatable.format(w)
+	}
+}
+
+func (t *boolTest) evaluator(v *validator) predicateEval {
+	if t.Comp != nil {
+		return t.Comp.evaluator(v)
+	}
+	return t.Negatable.evaluator(v)
+}
+
+type negatablePredicate struct {
+	Not  bool           `parser:"( @'NOT' )?"`
+	In   *inPredicate   `parser:"( @@"`
+	Like *likePredicate `parser:"| @@ )"`
+}
+
+func (p *negatablePredicate) format(w io.Writer) {
+	if p.Not {
+		io.WriteString(w, " NOT")
+	}
+	if p.In != nil {
+		p.In.format(w)
+	}
+	if p.Like != nil {
+		p.Like.format(w)
+	}
+}
+
+func (p *negatablePredicate) evaluator(v *validator) predicateEval {
+	var predicate predicateEval
+	if p.In != nil {
+		predicate = p.In.evaluator(v)
+	}
+	if p.Like != nil {
+		predicate = p.Like.evaluator(v)
+	}
+	if p.Not {
+		return func(f failure, s string) bool {
+			return !predicate(f, s)
+		}
+	}
+	return predicate
+}
+
+type compPredicate struct {
+	Op    string      `parser:"@( '=' | '!=' | '<>' )"`
+	Value *stringExpr `parser:"@@"`
+}
+
+func (p *compPredicate) format(w io.Writer) {
+	fmt.Fprintf(w, " %s ", p.Op)
+	p.Value.format(w)
+}
+
+func (p *compPredicate) evaluator(v *validator) predicateEval {
+	val := p.Value.evaluator(v)
+	switch p.Op {
+	case "=":
+		return func(f failure, s string) bool {
+			return s == val(f)
+		}
+	case "!=", "<>":
+		return func(f failure, s string) bool {
+			return s != val(f)
+		}
+	default:
+		panic("invalid op")
+	}
+}
+
+type inPredicate struct {
+	List []*stringExpr `parser:"'IN' '(' @@ ( ',' @@ )* ')'"`
+}
+
+func (p *inPredicate) format(w io.Writer) {
+	io.WriteString(w, " IN (")
+	for i, v := range p.List {
+		if i > 0 {
+			io.WriteString(w, ", ")
+		}
+		v.format(w)
+	}
+	io.WriteString(w, ")")
+}
+
+func (p *inPredicate) evaluator(v *validator) predicateEval {
+	var list []stringEval
+	for _, item := range p.List {
+		list = append(list, item.evaluator(v))
+	}
+	return func(f failure, s string) bool {
+		for _, item := range list {
+			if item(f) == s {
+				return true
+			}
+		}
+		return false
+	}
+}
+
+type likePredicate struct {
+	Pattern *string `parser:"'LIKE' @String"`
+}
+
+func (p *likePredicate) format(w io.Writer) {
+	io.WriteString(w, " LIKE ")
+	io.WriteString(w, *p.Pattern)
+}
+
+func (p *likePredicate) evaluator(v *validator) predicateEval {
+	likePattern, err := unescapeStringLiteral(*p.Pattern)
+	if err != nil {
+		v.reportError(err)
+		return nil
+	}
+
+	// Rewrite the LIKE syntax in terms of a regular expression syntax.
+	regexpPattern, err := likePatternToRegexp(likePattern)
+	if err != nil {
+		v.reportError(err)
+		return nil
+	}
+
+	re, err := regexp.Compile(regexpPattern)
+	if err != nil {
+		v.reportError(fmt.Errorf("invalid LIKE expression: %s", likePattern))
+		return nil
+	}
+	return func(f failure, s string) bool {
+		return re.MatchString(s)
+	}
+}
+
+type stringExpr struct {
+	Literal *string `parser:"@String"`
+	Ident   *string `parser:"| @Ident"`
+}
+
+func (e *stringExpr) format(w io.Writer) {
+	if e.Literal != nil {
+		io.WriteString(w, *e.Literal)
+	}
+	if e.Ident != nil {
+		io.WriteString(w, *e.Ident)
+	}
+}
+
+// asConstant attempts to evaluate stringExpr as a compile-time constant.
+// Returns the string value (assuming it is valid and constant) and
+// whether it is a constant.
+func (e *stringExpr) asConstant(v *validator) (value string, ok bool) {
+	if e.Literal != nil {
+		literal, err := unescapeStringLiteral(*e.Literal)
+		if err != nil {
+			v.reportError(err)
+			return "", true
+		}
+		return literal, true
+	}
+	return "", false
+}
+
+func (e *stringExpr) evaluator(v *validator) stringEval {
+	if e.Literal != nil {
+		literal, err := unescapeStringLiteral(*e.Literal)
+		if err != nil {
+			v.reportError(err)
+			return nil
+		}
+		return func(f failure) string { return literal }
+	}
+	if e.Ident != nil {
+		varName := *e.Ident
+		var accessor func(c *clustering.Failure) string
+		switch varName {
+		case "test":
+			accessor = func(f *clustering.Failure) string {
+				return f.TestID
+			}
+		case "reason":
+			accessor = func(f *clustering.Failure) string {
+				return f.Reason.GetPrimaryErrorMessage()
+			}
+		default:
+			v.reportError(fmt.Errorf("undeclared identifier %q", varName))
+		}
+		return func(f failure) string { return accessor(f) }
+	}
+	return nil
+}
+
+var (
+	lex = lexer.MustSimple([]lexer.Rule{
+		{Name: "whitespace", Pattern: `\s+`, Action: nil},
+		{Name: "Keyword", Pattern: `(?i)(TRUE|FALSE|AND|OR|NOT|LIKE|IN)\b`, Action: nil},
+		{Name: "Ident", Pattern: `([a-zA-Z_][a-zA-Z0-9_]*)\b`, Action: nil},
+		{Name: "String", Pattern: stringLiteralPattern, Action: nil},
+		{Name: "Operators", Pattern: `!=|<>|[,()=]`, Action: nil},
+	})
+
+	parser = participle.MustBuild(
+		&boolExpr{},
+		participle.Lexer(lex),
+		participle.Upper("Keyword"),
+		participle.Map(lowerMapper, "Ident"),
+		participle.CaseInsensitive("Keyword"))
+)
+
+func lowerMapper(token lexer.Token) (lexer.Token, error) {
+	token.Value = strings.ToLower(token.Value)
+	return token, nil
+}
+
+// Parse parses a failure association rule from the specified text.
+// idents is the set of identifiers that are recognised by the application.
+func Parse(text string) (*Expr, error) {
+	expr := &boolExpr{}
+	if err := parser.ParseString("", text, expr); err != nil {
+		return nil, errors.Annotate(err, "syntax error").Err()
+	}
+
+	v := newValidator()
+	eval := expr.evaluator(v)
+	if err := v.error(); err != nil {
+		return nil, err
+	}
+	return &Expr{
+		expr: expr,
+		eval: eval,
+	}, nil
+}
diff --git a/analysis/internal/clustering/rules/lang/lang_test.go b/analysis/internal/clustering/rules/lang/lang_test.go
new file mode 100644
index 0000000..067bf1d
--- /dev/null
+++ b/analysis/internal/clustering/rules/lang/lang_test.go
@@ -0,0 +1,283 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package lang
+
+import (
+	"fmt"
+	"strings"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	weetbixpb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestRules(t *testing.T) {
+	Convey(`Syntax Parsing`, t, func() {
+		parse := func(input string) error {
+			expr, err := Parse(input)
+			if err != nil {
+				So(expr, ShouldBeNil)
+			} else {
+				So(expr, ShouldNotBeNil)
+			}
+			return err
+		}
+		Convey(`Valid inputs`, func() {
+			validInputs := []string{
+				`false`,
+				`true`,
+				`true or true and not true`,
+				`(((true)))`,
+				`"" = "foo"`,
+				`"" = "'"`,
+				`"" = "\a\b\f\n\r\t\v\"\101\x42\u0042\U00000042"`,
+				`"" = test`,
+				`"" = TesT`,
+				`test = "foo"`,
+				`test != "foo"`,
+				`test <> "foo"`,
+				`test in ("foo", "bar", reason)`,
+				`test not in ("foo", "bar", reason)`,
+				`not test in ("foo", "bar", reason)`,
+				`test like "%arc%"`,
+				`test not like "%arc%"`,
+				`not test like "%arc%"`,
+				`regexp_contains(test, "^arc\\.")`,
+				`not regexp_contains(test, "^arc\\.")`,
+				`test = "arc.Boot" AND reason LIKE "%failed%"`,
+			}
+			for _, v := range validInputs {
+				So(parse(v), ShouldBeNil)
+			}
+		})
+		Convey(`Invalid inputs`, func() {
+			invalidInputs := []string{
+				`'' = 'foo'`,                  // Uses single quotes.
+				`"" = "\ud800"`,               // Illegal Unicode surrogate code point (D800-DFFF).
+				`"" = "\U00110000"`,           // Above maximum Unicode code point (10FFFF).
+				`"" = "\c"`,                   // Illegal escape sequence.
+				`"" = foo`,                    // Bad identifier.
+				`"" = ?`,                      // Bad identifier.
+				`test $ "foo"`,                // Invalid operator.
+				`test like build`,             // Use of non-constant like pattern.
+				`regexp_contains(test, "[")`,  // bad regexp.
+				`reason like "foo\\"`,         // invalid trailing "\" escape sequence in LIKE pattern.
+				`reason like "foo\\a"`,        // invalid escape sequence "\a" in LIKE pattern.
+				`regexp_contains(test, test)`, // Use of non-constant regexp pattern.
+				`regexp_contains(test)`,       // Incorrect argument count.
+				`bad_func(test, test)`,        // Undeclared function.
+				`reason NOTLIKE "%failed%"`,   // Bad operator.
+			}
+			for _, v := range invalidInputs {
+				So(parse(v), ShouldNotBeNil)
+			}
+		})
+	})
+	Convey(`Semantics`, t, func() {
+		eval := func(input string, failure *clustering.Failure) bool {
+			eval, err := Parse(input)
+			So(err, ShouldBeNil)
+			return eval.eval(failure)
+		}
+		boot := &clustering.Failure{
+			TestID: "tast.arc.Boot",
+			Reason: &weetbixpb.FailureReason{PrimaryErrorMessage: "annotation 1: annotation 2: failure"},
+		}
+		dbus := &clustering.Failure{
+			TestID: "tast.example.DBus",
+			Reason: &weetbixpb.FailureReason{PrimaryErrorMessage: "true was not true"},
+		}
+		Convey(`String Expression`, func() {
+			So(eval(`test = "tast.arc.Boot"`, boot), ShouldBeTrue)
+			So(eval(`test = "tast.arc.Boot"`, dbus), ShouldBeFalse)
+			So(eval(`test = test`, dbus), ShouldBeTrue)
+			escaping := &clustering.Failure{
+				TestID: "\a\b\f\n\r\t\v\"\101\x42\u0042\U00000042",
+			}
+			So(eval(`test = "\a\b\f\n\r\t\v\"\101\x42\u0042\U00000042"`, escaping), ShouldBeTrue)
+		})
+		Convey(`Boolean Constants`, func() {
+			So(eval(`TRUE`, boot), ShouldBeTrue)
+			So(eval(`tRue`, boot), ShouldBeTrue)
+			So(eval(`FALSE`, boot), ShouldBeFalse)
+		})
+		Convey(`Boolean Item`, func() {
+			So(eval(`(((TRUE)))`, boot), ShouldBeTrue)
+			So(eval(`(FALSE)`, boot), ShouldBeFalse)
+		})
+		Convey(`Boolean Predicate`, func() {
+			Convey(`Comp`, func() {
+				So(eval(`test = "tast.arc.Boot"`, boot), ShouldBeTrue)
+				So(eval(`test = "tast.arc.Boot"`, dbus), ShouldBeFalse)
+				So(eval(`test <> "tast.arc.Boot"`, boot), ShouldBeFalse)
+				So(eval(`test <> "tast.arc.Boot"`, dbus), ShouldBeTrue)
+				So(eval(`test != "tast.arc.Boot"`, boot), ShouldBeFalse)
+				So(eval(`test != "tast.arc.Boot"`, dbus), ShouldBeTrue)
+			})
+			Convey(`Negatable`, func() {
+				So(eval(`test NOT LIKE "tast.arc.%"`, boot), ShouldBeFalse)
+				So(eval(`test NOT LIKE "tast.arc.%"`, dbus), ShouldBeTrue)
+				So(eval(`test LIKE "tast.arc.%"`, boot), ShouldBeTrue)
+				So(eval(`test LIKE "tast.arc.%"`, dbus), ShouldBeFalse)
+			})
+			Convey(`Like`, func() {
+				So(eval(`test LIKE "tast.arc.%"`, boot), ShouldBeTrue)
+				So(eval(`test LIKE "tast.arc.%"`, dbus), ShouldBeFalse)
+				So(eval(`test LIKE "arc.%"`, boot), ShouldBeFalse)
+				So(eval(`test LIKE ".Boot"`, boot), ShouldBeFalse)
+				So(eval(`test LIKE "%arc.%"`, boot), ShouldBeTrue)
+				So(eval(`test LIKE "%.Boot"`, boot), ShouldBeTrue)
+				So(eval(`test LIKE "tast.%.Boot"`, boot), ShouldBeTrue)
+
+				escapeTest := &clustering.Failure{
+					TestID: "a\\.+*?()|[]{}^$a",
+				}
+				So(eval(`test LIKE "\\\\.+*?()|[]{}^$a"`, escapeTest), ShouldBeFalse)
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$"`, escapeTest), ShouldBeFalse)
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$a"`, escapeTest), ShouldBeTrue)
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$_"`, escapeTest), ShouldBeTrue)
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$%"`, escapeTest), ShouldBeTrue)
+
+				escapeTest2 := &clustering.Failure{
+					TestID: "a\\.+*?()|[]{}^$_",
+				}
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$\\_"`, escapeTest), ShouldBeFalse)
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$\\_"`, escapeTest2), ShouldBeTrue)
+
+				escapeTest3 := &clustering.Failure{
+					TestID: "a\\.+*?()|[]{}^$%",
+				}
+
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$\\%"`, escapeTest), ShouldBeFalse)
+				So(eval(`test LIKE "a\\\\.+*?()|[]{}^$\\%"`, escapeTest3), ShouldBeTrue)
+
+				escapeTest4 := &clustering.Failure{
+					Reason: &weetbixpb.FailureReason{
+						PrimaryErrorMessage: "a\nb",
+					},
+				}
+				So(eval(`reason LIKE "a"`, escapeTest4), ShouldBeFalse)
+				So(eval(`reason LIKE "%"`, escapeTest4), ShouldBeTrue)
+				So(eval(`reason LIKE "a%b"`, escapeTest4), ShouldBeTrue)
+				So(eval(`reason LIKE "a_b"`, escapeTest4), ShouldBeTrue)
+			})
+			Convey(`In`, func() {
+				So(eval(`test IN ("tast.arc.Boot")`, boot), ShouldBeTrue)
+				So(eval(`test IN ("tast.arc.Clipboard", "tast.arc.Boot")`, boot), ShouldBeTrue)
+				So(eval(`test IN ("tast.arc.Clipboard", "tast.arc.Boot")`, dbus), ShouldBeFalse)
+			})
+		})
+		Convey(`Boolean Function`, func() {
+			So(eval(`REGEXP_CONTAINS(test, "tast\\.arc\\..*")`, boot), ShouldBeTrue)
+			So(eval(`REGEXP_CONTAINS(test, "tast\\.arc\\..*")`, dbus), ShouldBeFalse)
+		})
+		Convey(`Boolean Factor`, func() {
+			So(eval(`NOT TRUE`, boot), ShouldBeFalse)
+			So(eval(`NOT FALSE`, boot), ShouldBeTrue)
+			So(eval(`NOT REGEXP_CONTAINS(test, "tast\\.arc\\..*")`, boot), ShouldBeFalse)
+			So(eval(`NOT REGEXP_CONTAINS(test, "tast\\.arc\\..*")`, dbus), ShouldBeTrue)
+		})
+		Convey(`Boolean Term`, func() {
+			So(eval(`TRUE AND TRUE`, boot), ShouldBeTrue)
+			So(eval(`TRUE AND FALSE`, boot), ShouldBeFalse)
+			So(eval(`NOT FALSE AND NOT FALSE`, boot), ShouldBeTrue)
+			So(eval(`NOT FALSE AND NOT FALSE AND NOT FALSE`, boot), ShouldBeTrue)
+		})
+		Convey(`Boolean Expression`, func() {
+			So(eval(`TRUE OR FALSE`, boot), ShouldBeTrue)
+			So(eval(`FALSE AND FALSE OR TRUE`, boot), ShouldBeTrue)
+			So(eval(`FALSE AND TRUE OR FALSE OR FALSE AND TRUE`, boot), ShouldBeFalse)
+		})
+	})
+	Convey(`Formatting`, t, func() {
+		roundtrip := func(input string) string {
+			eval, err := Parse(input)
+			So(err, ShouldBeNil)
+			return eval.String()
+		}
+		// The following statements should be formatted exactly the same when they are printed.
+		inputs := []string{
+			`FALSE`,
+			`TRUE`,
+			`TRUE OR TRUE AND NOT TRUE`,
+			`(((TRUE)))`,
+			`"" = "foo"`,
+			`"" = "'"`,
+			`"" = "\a\b\f\n\r\t\v\"\101\x42\u0042\U00000042"`,
+			`"" = test`,
+			`test = "foo"`,
+			`test != "foo"`,
+			`test <> "foo"`,
+			`test IN ("foo", "bar", reason)`,
+			`test NOT IN ("foo", "bar", reason)`,
+			`NOT test IN ("foo", "bar", reason)`,
+			`test LIKE "%arc%"`,
+			`test NOT LIKE "%arc%"`,
+			`NOT test LIKE "%arc%"`,
+			`regexp_contains(test, "^arc\\.")`,
+			`NOT regexp_contains(test, "^arc\\.")`,
+			`test = "arc.Boot" AND reason LIKE "%failed%"`,
+		}
+		for _, input := range inputs {
+			So(roundtrip(input), ShouldEqual, input)
+		}
+	})
+}
+
+// On my machine, I get the following reuslts:
+// cpu: Intel(R) Xeon(R) CPU @ 2.00GHz
+// BenchmarkRules-48    	      51	  22406568 ns/op	     481 B/op	       0 allocs/op
+func BenchmarkRules(b *testing.B) {
+	// Setup 1000 rules.
+	var rules []*Expr
+	for i := 0; i < 1000; i++ {
+		rule := `test LIKE "%arc.Boot` + fmt.Sprintf("%v", i) + `.%" AND reason LIKE "%failed` + fmt.Sprintf("%v", i) + `.%"`
+		expr, err := Parse(rule)
+		if err != nil {
+			b.Error(err)
+		}
+		rules = append(rules, expr)
+	}
+	var testText strings.Builder
+	var reasonText strings.Builder
+	for j := 0; j < 100; j++ {
+		testText.WriteString("blah")
+		reasonText.WriteString("blah")
+	}
+	testText.WriteString("arc.Boot0.")
+	reasonText.WriteString("failed0.")
+	for j := 0; j < 100; j++ {
+		testText.WriteString("blah")
+		reasonText.WriteString("blah")
+	}
+	data := &clustering.Failure{
+		TestID: testText.String(),
+		Reason: &weetbixpb.FailureReason{PrimaryErrorMessage: reasonText.String()},
+	}
+
+	// Start benchmark.
+	b.ResetTimer()
+	for n := 0; n < b.N; n++ {
+		for j, r := range rules {
+			matches := r.Evaluate(data)
+			shouldMatch := j == 0
+			if matches != shouldMatch {
+				b.Errorf("Unexpected result at %v: got %v, want %v", j, matches, shouldMatch)
+			}
+		}
+	}
+}
diff --git a/analysis/internal/clustering/rules/main_test.go b/analysis/internal/clustering/rules/main_test.go
new file mode 100644
index 0000000..67e84bf
--- /dev/null
+++ b/analysis/internal/clustering/rules/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rules
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/rules/span.go b/analysis/internal/clustering/rules/span.go
new file mode 100644
index 0000000..145deea
--- /dev/null
+++ b/analysis/internal/clustering/rules/span.go
@@ -0,0 +1,470 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rules
+
+import (
+	"context"
+	"crypto/rand"
+	"encoding/hex"
+	"regexp"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/lang"
+	"go.chromium.org/luci/analysis/internal/config"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+)
+
+// RuleIDRe is the regular expression pattern that matches validly
+// formed rule IDs.
+const RuleIDRePattern = `[0-9a-f]{32}`
+
+// RuleIDRe matches validly formed rule IDs.
+var RuleIDRe = regexp.MustCompile(`^` + RuleIDRePattern + `$`)
+
+// UserRe matches valid users. These are email addresses or the special
+// value "weetbix".
+var UserRe = regexp.MustCompile(`^weetbix|([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)$`)
+
+// WeetbixSystem is the special user that identifies changes made by the
+// Weetbix system itself in audit fields.
+const WeetbixSystem = "weetbix"
+
+// StartingEpoch is the rule last updated time used for projects that have
+// no rules (active or otherwise). It is deliberately different from the
+// timestamp zero value to be discernible from "timestamp not populated"
+// programming errors.
+var StartingEpoch = time.Date(1900, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+// StartingEpoch is the rule version used for projects that have
+// no rules (active or otherwise).
+var StartingVersion = Version{
+	Predicates: StartingEpoch,
+	Total:      StartingEpoch,
+}
+
+// NotExistsErr is returned by Read methods for a single failure
+// association rule, if no matching rule exists.
+var NotExistsErr = errors.New("no matching rule exists")
+
+// FailureAssociationRule associates failures with a bug. When the rule
+// is used to match incoming test failures, the resultant cluster is
+// known as a 'bug cluster' because the cluster is associated with a bug
+// (via the failure association rule).
+type FailureAssociationRule struct {
+	// The LUCI Project for which this rule is defined.
+	Project string `json:"project"`
+	// The unique identifier for the failure association rule,
+	// as 32 lowercase hexadecimal characters.
+	RuleID string `json:"ruleId"`
+	// The rule predicate, defining which failures are being associated.
+	RuleDefinition string `json:"ruleDefinition"`
+	// The time the rule was created. Output only.
+	CreationTime time.Time `json:"creationTime"`
+	// The user which created the rule. Output only.
+	CreationUser string `json:"creationUser"`
+	// The time the rule was last updated. Output only.
+	LastUpdated time.Time `json:"lastUpdated"`
+	// The user which last updated the rule. Output only.
+	LastUpdatedUser string `json:"lastUpdatedUser"`
+	// The time the rule was last updated in a way that caused the
+	// matched failures to change, i.e. because of a change to RuleDefinition
+	// or IsActive. (By contrast, updating BugID does NOT change
+	// the matched failures, so does NOT update this field.)
+	// When this value changes, it triggers re-clustering.
+	// Compare with RulesVersion on ReclusteringRuns to identify
+	// reclustering state.
+	// Output only.
+	PredicateLastUpdated time.Time `json:"predicateLastUpdated"`
+	// BugID is the identifier of the bug that the failures are
+	// associated with.
+	BugID bugs.BugID `json:"bugId"`
+	// Whether the bug should be updated by Weetbix, and whether failures
+	// should still be matched against the rule.
+	IsActive bool `json:"isActive"`
+	// Whether this rule should manage the priority and verified status
+	// of the associated bug based on the impact of the cluster defined
+	// by this rule.
+	IsManagingBug bool `json:"isManagingBug"`
+	// The suggested cluster this rule was created from (if any).
+	// Until re-clustering is complete and has reduced the residual impact
+	// of the source cluster, this cluster ID tells bug filing to ignore
+	// the source cluster when determining whether new bugs need to be filed.
+	SourceCluster clustering.ClusterID `json:"sourceCluster"`
+}
+
+// Read reads the failure association rule with the given rule ID.
+// If no rule exists, NotExistsErr will be returned.
+func Read(ctx context.Context, project string, id string) (*FailureAssociationRule, error) {
+	whereClause := `Project = @project AND RuleId = @ruleId`
+	params := map[string]interface{}{
+		"project": project,
+		"ruleId":  id,
+	}
+	rs, err := readWhere(ctx, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query rule by id").Err()
+	}
+	if len(rs) == 0 {
+		return nil, NotExistsErr
+	}
+	return rs[0], nil
+}
+
+// ReadAll reads all Weetbix failure association rules in a given project.
+// This method is not expected to scale -- for testing use only.
+func ReadAll(ctx context.Context, project string) ([]*FailureAssociationRule, error) {
+	whereClause := `Project = @project`
+	params := map[string]interface{}{
+		"project": project,
+	}
+	rs, err := readWhere(ctx, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query all rules").Err()
+	}
+	return rs, nil
+}
+
+// ReadActive reads all active Weetbix failure association rules in the given LUCI project.
+func ReadActive(ctx context.Context, project string) ([]*FailureAssociationRule, error) {
+	whereClause := `Project = @project AND IsActive`
+	params := map[string]interface{}{
+		"project": project,
+	}
+	rs, err := readWhere(ctx, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query active rules").Err()
+	}
+	return rs, nil
+}
+
+// ReadByBug reads the failure association rules associated with the given bug.
+// At most one rule will be returned per project.
+func ReadByBug(ctx context.Context, bugID bugs.BugID) ([]*FailureAssociationRule, error) {
+	whereClause := `BugSystem = @bugSystem and BugId = @bugId`
+	params := map[string]interface{}{
+		"bugSystem": bugID.System,
+		"bugId":     bugID.ID,
+	}
+	rs, err := readWhere(ctx, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query rule by bug").Err()
+	}
+	return rs, nil
+}
+
+// ReadDelta reads the changed failure association rules since the given
+// timestamp, in the given LUCI project.
+func ReadDelta(ctx context.Context, project string, sinceTime time.Time) ([]*FailureAssociationRule, error) {
+	if sinceTime.Before(StartingEpoch) {
+		return nil, errors.New("cannot query rule deltas from before project inception")
+	}
+	whereClause := `Project = @project AND LastUpdated > @sinceTime`
+	params := map[string]interface{}{
+		"project":   project,
+		"sinceTime": sinceTime,
+	}
+	rs, err := readWhere(ctx, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query rules since").Err()
+	}
+	return rs, nil
+}
+
+// ReadMany reads the failure association rules with the given rule IDs.
+// The returned slice of rules will correspond one-to-one the IDs requested
+// (so returned[i].RuleId == ids[i], assuming the rule exists, else
+// returned[i] == nil). If a rule does not exist, a value of nil will be
+// returned for that ID. The same rule can be requested multiple times.
+func ReadMany(ctx context.Context, project string, ids []string) ([]*FailureAssociationRule, error) {
+	whereClause := `Project = @project AND RuleId IN UNNEST(@ruleIds)`
+	params := map[string]interface{}{
+		"project": project,
+		"ruleIds": ids,
+	}
+	rs, err := readWhere(ctx, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query rules by id").Err()
+	}
+	ruleByID := make(map[string]FailureAssociationRule)
+	for _, r := range rs {
+		ruleByID[r.RuleID] = *r
+	}
+	var result []*FailureAssociationRule
+	for _, id := range ids {
+		var entry *FailureAssociationRule
+		rule, ok := ruleByID[id]
+		if ok {
+			// Copy the rule to ensure the rules in the result
+			// are not aliased, even if the same rule ID is requested
+			// multiple times.
+			entry = new(FailureAssociationRule)
+			*entry = rule
+		}
+		result = append(result, entry)
+	}
+	return result, nil
+}
+
+// readWhere failure association rules matching the given where clause,
+// substituting params for any SQL parameters used in that clause.
+func readWhere(ctx context.Context, whereClause string, params map[string]interface{}) ([]*FailureAssociationRule, error) {
+	stmt := spanner.NewStatement(`
+		SELECT Project, RuleId, RuleDefinition, BugSystem, BugId,
+		  CreationTime, LastUpdated, PredicateLastUpdated,
+		  CreationUser, LastUpdatedUser,
+		  IsActive, IsManagingBug,
+		  SourceClusterAlgorithm, SourceClusterId
+		FROM FailureAssociationRules
+		WHERE (` + whereClause + `)
+		ORDER BY BugSystem, BugId, Project
+	`)
+	stmt.Params = params
+
+	it := span.Query(ctx, stmt)
+	rs := []*FailureAssociationRule{}
+	err := it.Do(func(r *spanner.Row) error {
+		var project, ruleID, ruleDefinition, bugSystem, bugID string
+		var creationTime, lastUpdated, predicateLastUpdated time.Time
+		var creationUser, lastUpdatedUser string
+		var isActive, isManagingBug spanner.NullBool
+		var sourceClusterAlgorithm, sourceClusterID string
+		err := r.Columns(
+			&project, &ruleID, &ruleDefinition, &bugSystem, &bugID,
+			&creationTime, &lastUpdated, &predicateLastUpdated,
+			&creationUser, &lastUpdatedUser,
+			&isActive, &isManagingBug,
+			&sourceClusterAlgorithm, &sourceClusterID,
+		)
+		if err != nil {
+			return errors.Annotate(err, "read rule row").Err()
+		}
+
+		rule := &FailureAssociationRule{
+			Project:              project,
+			RuleID:               ruleID,
+			RuleDefinition:       ruleDefinition,
+			CreationTime:         creationTime,
+			CreationUser:         creationUser,
+			LastUpdated:          lastUpdated,
+			LastUpdatedUser:      lastUpdatedUser,
+			PredicateLastUpdated: predicateLastUpdated,
+			BugID:                bugs.BugID{System: bugSystem, ID: bugID},
+			IsActive:             isActive.Valid && isActive.Bool,
+			IsManagingBug:        isManagingBug.Valid && isManagingBug.Bool,
+			SourceCluster: clustering.ClusterID{
+				Algorithm: sourceClusterAlgorithm,
+				ID:        sourceClusterID,
+			},
+		}
+		rs = append(rs, rule)
+		return nil
+	})
+	return rs, err
+}
+
+// Version captures version information about a project's rules.
+type Version struct {
+	// Predicates is the last time any rule changed its
+	// rule predicate (RuleDefinition or IsActive).
+	// Also known as "Rules Version" in clustering contexts.
+	Predicates time.Time
+	// Total is the last time any rule was updated in any way.
+	// Pass to ReadDelta when seeking to read changed rules.
+	Total time.Time
+}
+
+// ReadVersion reads information about when rules in the given project
+// were last updated. This is used to version the set of rules retrieved
+// by ReadActive and is typically called in the same transaction.
+// It is also used to implement change detection on rule predicates
+// for the purpose of triggering re-clustering.
+//
+// Simply reading the last LastUpdated time of the rules read by ReadActive
+// is not sufficient to version the set of rules read, as the most recent
+// update may have been to mark a rule inactive (removing it from the set
+// that is read).
+//
+// If the project has no failure association rules, the timestamp
+// StartingEpoch is returned.
+func ReadVersion(ctx context.Context, projectID string) (Version, error) {
+	stmt := spanner.NewStatement(`
+		SELECT
+		  Max(PredicateLastUpdated) as PredicateLastUpdated,
+		  MAX(LastUpdated) as LastUpdated
+		FROM FailureAssociationRules
+		WHERE Project = @projectID
+	`)
+	stmt.Params = map[string]interface{}{
+		"projectID": projectID,
+	}
+	var predicateLastUpdated, lastUpdated spanner.NullTime
+	it := span.Query(ctx, stmt)
+	err := it.Do(func(r *spanner.Row) error {
+		err := r.Columns(&predicateLastUpdated, &lastUpdated)
+		if err != nil {
+			return errors.Annotate(err, "read last updated row").Err()
+		}
+		return nil
+	})
+	if err != nil {
+		return Version{}, errors.Annotate(err, "query last updated").Err()
+	}
+	result := Version{
+		Predicates: StartingEpoch,
+		Total:      StartingEpoch,
+	}
+	// predicateLastUpdated / lastUpdated are only invalid if there
+	// are no failure association rules.
+	if predicateLastUpdated.Valid {
+		result.Predicates = predicateLastUpdated.Time
+	}
+	if lastUpdated.Valid {
+		result.Total = lastUpdated.Time
+	}
+	return result, nil
+}
+
+// ReadTotalActiveRules reads the number active rules, for each LUCI Project.
+// Only returns entries for projects that have any rules (at all). Combine
+// with config if you need zero entries for projects that are defined but
+// have no rules.
+func ReadTotalActiveRules(ctx context.Context) (map[string]int64, error) {
+	stmt := spanner.NewStatement(`
+		SELECT
+		  project,
+		  COUNTIF(IsActive) as active_rules,
+		FROM FailureAssociationRules
+		GROUP BY project
+	`)
+	result := make(map[string]int64)
+	it := span.Query(ctx, stmt)
+	err := it.Do(func(r *spanner.Row) error {
+		var project string
+		var activeRules int64
+		err := r.Columns(&project, &activeRules)
+		if err != nil {
+			return errors.Annotate(err, "read row").Err()
+		}
+		result[project] = activeRules
+		return nil
+	})
+	if err != nil {
+		return nil, errors.Annotate(err, "query total active rules by project").Err()
+	}
+	return result, nil
+}
+
+// Create inserts a new failure association rule with the specified details.
+func Create(ctx context.Context, r *FailureAssociationRule, user string) error {
+	if err := validateRule(r); err != nil {
+		return err
+	}
+	if err := validateUser(user); err != nil {
+		return err
+	}
+	ms := spanutil.InsertMap("FailureAssociationRules", map[string]interface{}{
+		"Project":              r.Project,
+		"RuleId":               r.RuleID,
+		"RuleDefinition":       r.RuleDefinition,
+		"PredicateLastUpdated": spanner.CommitTimestamp,
+		"CreationTime":         spanner.CommitTimestamp,
+		"CreationUser":         user,
+		"LastUpdated":          spanner.CommitTimestamp,
+		"LastUpdatedUser":      user,
+		"BugSystem":            r.BugID.System,
+		"BugId":                r.BugID.ID,
+		// IsActive uses the value 'NULL' to indicate false, and true to indicate true.
+		"IsActive":               spanner.NullBool{Bool: r.IsActive, Valid: r.IsActive},
+		"IsManagingBug":          r.IsManagingBug,
+		"SourceClusterAlgorithm": r.SourceCluster.Algorithm,
+		"SourceClusterId":        r.SourceCluster.ID,
+	})
+	span.BufferWrite(ctx, ms)
+	return nil
+}
+
+// Update updates an existing failure association rule to have the specified
+// details. Set updatePredicate to true if you changed RuleDefinition
+// or IsActive.
+func Update(ctx context.Context, r *FailureAssociationRule, updatePredicate bool, user string) error {
+	if err := validateRule(r); err != nil {
+		return err
+	}
+	if err := validateUser(user); err != nil {
+		return err
+	}
+	update := map[string]interface{}{
+		"Project":                r.Project,
+		"RuleId":                 r.RuleID,
+		"LastUpdated":            spanner.CommitTimestamp,
+		"LastUpdatedUser":        user,
+		"BugSystem":              r.BugID.System,
+		"BugId":                  r.BugID.ID,
+		"SourceClusterAlgorithm": r.SourceCluster.Algorithm,
+		"SourceClusterId":        r.SourceCluster.ID,
+		"IsManagingBug":          r.IsManagingBug,
+	}
+	if updatePredicate {
+		update["RuleDefinition"] = r.RuleDefinition
+		// IsActive uses the value 'NULL' to indicate false, and true to indicate true.
+		update["IsActive"] = spanner.NullBool{Bool: r.IsActive, Valid: r.IsActive}
+		update["PredicateLastUpdated"] = spanner.CommitTimestamp
+	}
+	ms := spanutil.UpdateMap("FailureAssociationRules", update)
+	span.BufferWrite(ctx, ms)
+	return nil
+}
+
+func validateRule(r *FailureAssociationRule) error {
+	switch {
+	case !config.ProjectRe.MatchString(r.Project):
+		return errors.New("project must be valid")
+	case !RuleIDRe.MatchString(r.RuleID):
+		return errors.New("rule ID must be valid")
+	case r.BugID.Validate() != nil:
+		return errors.Annotate(r.BugID.Validate(), "bug ID is not valid").Err()
+	case r.SourceCluster.Validate() != nil && !r.SourceCluster.IsEmpty():
+		return errors.Annotate(r.SourceCluster.Validate(), "source cluster ID is not valid").Err()
+	}
+	_, err := lang.Parse(r.RuleDefinition)
+	if err != nil {
+		return errors.Annotate(err, "rule definition is not valid").Err()
+	}
+	return nil
+}
+
+func validateUser(u string) error {
+	if !UserRe.MatchString(u) {
+		return errors.New("user must be valid")
+	}
+	return nil
+}
+
+// GenerateID returns a random 128-bit rule ID, encoded as
+// 32 lowercase hexadecimal characters.
+func GenerateID() (string, error) {
+	randomBytes := make([]byte, 16)
+	_, err := rand.Read(randomBytes)
+	if err != nil {
+		return "", err
+	}
+	return hex.EncodeToString(randomBytes), nil
+}
diff --git a/analysis/internal/clustering/rules/span_test.go b/analysis/internal/clustering/rules/span_test.go
new file mode 100644
index 0000000..e2eb540
--- /dev/null
+++ b/analysis/internal/clustering/rules/span_test.go
@@ -0,0 +1,440 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rules
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestSpan(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		Convey(`Read`, func() {
+			Convey(`Not Exists`, func() {
+				ruleID := strings.Repeat("00", 16)
+				rule, err := Read(span.Single(ctx), testProject, ruleID)
+				So(err, ShouldEqual, NotExistsErr)
+				So(rule, ShouldBeNil)
+			})
+			Convey(`Exists`, func() {
+				expectedRule := NewRule(100).Build()
+				err := SetRulesForTesting(ctx, []*FailureAssociationRule{expectedRule})
+				So(err, ShouldBeNil)
+
+				rule, err := Read(span.Single(ctx), testProject, expectedRule.RuleID)
+				So(err, ShouldBeNil)
+				So(rule, ShouldResemble, expectedRule)
+			})
+		})
+		Convey(`ReadActive`, func() {
+			Convey(`Empty`, func() {
+				err := SetRulesForTesting(ctx, nil)
+				So(err, ShouldBeNil)
+
+				rules, err := ReadActive(span.Single(ctx), testProject)
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, []*FailureAssociationRule{})
+			})
+			Convey(`Multiple`, func() {
+				rulesToCreate := []*FailureAssociationRule{
+					NewRule(0).Build(),
+					NewRule(1).WithProject("otherproject").Build(),
+					NewRule(2).WithActive(false).Build(),
+					NewRule(3).Build(),
+				}
+				err := SetRulesForTesting(ctx, rulesToCreate)
+				So(err, ShouldBeNil)
+
+				rules, err := ReadActive(span.Single(ctx), testProject)
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, []*FailureAssociationRule{
+					rulesToCreate[3],
+					rulesToCreate[0],
+				})
+			})
+		})
+		Convey(`ReadByBug`, func() {
+			bugID := bugs.BugID{System: "monorail", ID: "monorailproject/1"}
+			Convey(`Empty`, func() {
+				rules, err := ReadByBug(span.Single(ctx), bugID)
+				So(err, ShouldBeNil)
+				So(rules, ShouldBeEmpty)
+			})
+			Convey(`Multiple`, func() {
+				expectedRule := NewRule(100).
+					WithProject("testproject").
+					WithBug(bugID).
+					Build()
+				expectedRule2 := NewRule(100).
+					WithProject("testproject2").
+					WithBug(bugID).
+					WithBugManaged(false).
+					Build()
+				expectedRules := []*FailureAssociationRule{expectedRule, expectedRule2}
+				err := SetRulesForTesting(ctx, expectedRules)
+				So(err, ShouldBeNil)
+
+				rules, err := ReadByBug(span.Single(ctx), bugID)
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, expectedRules)
+			})
+		})
+		Convey(`ReadDelta`, func() {
+			Convey(`Invalid since time`, func() {
+				_, err := ReadDelta(span.Single(ctx), testProject, time.Time{})
+				So(err, ShouldErrLike, "cannot query rule deltas from before project inception")
+			})
+			Convey(`Empty`, func() {
+				err := SetRulesForTesting(ctx, nil)
+				So(err, ShouldBeNil)
+
+				rules, err := ReadDelta(span.Single(ctx), testProject, StartingEpoch)
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, []*FailureAssociationRule{})
+			})
+			Convey(`Multiple`, func() {
+				reference := time.Date(2020, 1, 2, 3, 4, 5, 6000, time.UTC)
+				rulesToCreate := []*FailureAssociationRule{
+					NewRule(0).WithLastUpdated(reference).Build(),
+					NewRule(1).WithProject("otherproject").WithLastUpdated(reference.Add(time.Minute)).Build(),
+					NewRule(2).WithActive(false).WithLastUpdated(reference.Add(time.Minute)).Build(),
+					NewRule(3).WithLastUpdated(reference.Add(time.Microsecond)).Build(),
+				}
+				err := SetRulesForTesting(ctx, rulesToCreate)
+				So(err, ShouldBeNil)
+
+				rules, err := ReadDelta(span.Single(ctx), testProject, StartingEpoch)
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, []*FailureAssociationRule{
+					rulesToCreate[3],
+					rulesToCreate[0],
+					rulesToCreate[2],
+				})
+
+				rules, err = ReadDelta(span.Single(ctx), testProject, reference)
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, []*FailureAssociationRule{
+					rulesToCreate[3],
+					rulesToCreate[2],
+				})
+
+				rules, err = ReadDelta(span.Single(ctx), testProject, reference.Add(time.Minute))
+				So(err, ShouldBeNil)
+				So(rules, ShouldResemble, []*FailureAssociationRule{})
+			})
+		})
+		Convey(`ReadMany`, func() {
+			rulesToCreate := []*FailureAssociationRule{
+				NewRule(0).Build(),
+				NewRule(1).WithProject("otherproject").Build(),
+				NewRule(2).WithActive(false).Build(),
+				NewRule(3).Build(),
+			}
+			err := SetRulesForTesting(ctx, rulesToCreate)
+			So(err, ShouldBeNil)
+
+			ids := []string{
+				rulesToCreate[0].RuleID,
+				rulesToCreate[1].RuleID, // Should not exist, exists in different project.
+				rulesToCreate[2].RuleID,
+				rulesToCreate[3].RuleID,
+				strings.Repeat("01", 16), // Non-existent ID, should not exist.
+				strings.Repeat("02", 16), // Non-existent ID, should not exist.
+				rulesToCreate[2].RuleID,  // Repeat of existent ID.
+				strings.Repeat("01", 16), // Repeat of non-existent ID, should not exist.
+			}
+			rules, err := ReadMany(span.Single(ctx), testProject, ids)
+			So(err, ShouldBeNil)
+			So(rules, ShouldResemble, []*FailureAssociationRule{
+				rulesToCreate[0],
+				nil,
+				rulesToCreate[2],
+				rulesToCreate[3],
+				nil,
+				nil,
+				rulesToCreate[2],
+				nil,
+			})
+		})
+		Convey(`ReadVersion`, func() {
+			Convey(`Empty`, func() {
+				err := SetRulesForTesting(ctx, nil)
+				So(err, ShouldBeNil)
+
+				timestamp, err := ReadVersion(span.Single(ctx), testProject)
+				So(err, ShouldBeNil)
+				So(timestamp, ShouldResemble, StartingVersion)
+			})
+			Convey(`Multiple`, func() {
+				// Spanner commit timestamps are in microsecond
+				// (not nanosecond) granularity. The MAX operator
+				// on timestamps truncates to microseconds. For this
+				// reason, we use microsecond resolution timestamps
+				// when testing.
+				reference := time.Date(2020, 1, 2, 3, 4, 5, 6000, time.UTC)
+				rulesToCreate := []*FailureAssociationRule{
+					NewRule(0).
+						WithPredicateLastUpdated(reference.Add(-1 * time.Hour)).
+						WithLastUpdated(reference.Add(-1 * time.Hour)).
+						Build(),
+					NewRule(1).WithProject("otherproject").
+						WithPredicateLastUpdated(reference.Add(time.Hour)).
+						WithLastUpdated(reference.Add(time.Hour)).
+						Build(),
+					NewRule(2).WithActive(false).
+						WithPredicateLastUpdated(reference.Add(-1 * time.Second)).
+						WithLastUpdated(reference).
+						Build(),
+					NewRule(3).
+						WithPredicateLastUpdated(reference.Add(-2 * time.Hour)).
+						WithLastUpdated(reference.Add(-2 * time.Hour)).
+						Build(),
+				}
+				err := SetRulesForTesting(ctx, rulesToCreate)
+				So(err, ShouldBeNil)
+
+				version, err := ReadVersion(span.Single(ctx), testProject)
+				So(err, ShouldBeNil)
+				So(version, ShouldResemble, Version{
+					Predicates: reference.Add(-1 * time.Second),
+					Total:      reference,
+				})
+			})
+		})
+		Convey(`ReadTotalActiveRules`, func() {
+			Convey(`Empty`, func() {
+				err := SetRulesForTesting(ctx, nil)
+				So(err, ShouldBeNil)
+
+				result, err := ReadTotalActiveRules(span.Single(ctx))
+				So(err, ShouldBeNil)
+				So(result, ShouldResemble, map[string]int64{})
+			})
+			Convey(`Multiple`, func() {
+				rulesToCreate := []*FailureAssociationRule{
+					// Two active and one inactive rule for Project A.
+					NewRule(0).WithProject("project-a").WithActive(true).Build(),
+					NewRule(1).WithProject("project-a").WithActive(false).Build(),
+					NewRule(2).WithProject("project-a").WithActive(true).Build(),
+					// One inactive rule for Project B.
+					NewRule(3).WithProject("project-b").WithActive(false).Build(),
+					// One active rule for Project C.
+					NewRule(4).WithProject("project-c").WithActive(true).Build(),
+				}
+				err := SetRulesForTesting(ctx, rulesToCreate)
+				So(err, ShouldBeNil)
+
+				result, err := ReadTotalActiveRules(span.Single(ctx))
+				So(err, ShouldBeNil)
+				So(result, ShouldResemble, map[string]int64{
+					"project-a": 2,
+					"project-b": 0,
+					"project-c": 1,
+				})
+			})
+		})
+		Convey(`Create`, func() {
+			testCreate := func(bc *FailureAssociationRule, user string) (time.Time, error) {
+				commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					return Create(ctx, bc, user)
+				})
+				return commitTime.In(time.UTC), err
+			}
+			r := NewRule(100).Build()
+			r.CreationUser = WeetbixSystem
+			r.LastUpdatedUser = WeetbixSystem
+
+			Convey(`Valid`, func() {
+				testExists := func(expectedRule FailureAssociationRule) {
+					txn, cancel := span.ReadOnlyTransaction(ctx)
+					defer cancel()
+					rules, err := ReadActive(txn, testProject)
+
+					So(err, ShouldBeNil)
+					So(len(rules), ShouldEqual, 1)
+
+					readRule := rules[0]
+					So(*readRule, ShouldResemble, expectedRule)
+				}
+
+				Convey(`With Source Cluster`, func() {
+					So(r.SourceCluster.Algorithm, ShouldNotBeEmpty)
+					So(r.SourceCluster.ID, ShouldNotBeNil)
+					commitTime, err := testCreate(r, WeetbixSystem)
+					So(err, ShouldBeNil)
+
+					expectedRule := *r
+					expectedRule.LastUpdated = commitTime
+					expectedRule.PredicateLastUpdated = commitTime
+					expectedRule.CreationTime = commitTime
+					testExists(expectedRule)
+				})
+				Convey(`Without Source Cluster`, func() {
+					// E.g. in case of a manually created rule.
+					r.SourceCluster = clustering.ClusterID{}
+					r.CreationUser = "user@google.com"
+					r.LastUpdatedUser = "user@google.com"
+					commitTime, err := testCreate(r, "user@google.com")
+					So(err, ShouldBeNil)
+
+					expectedRule := *r
+					expectedRule.LastUpdated = commitTime
+					expectedRule.PredicateLastUpdated = commitTime
+					expectedRule.CreationTime = commitTime
+					testExists(expectedRule)
+				})
+				Convey(`With Buganizer Bug`, func() {
+					r.BugID = bugs.BugID{System: "buganizer", ID: "1234567890"}
+					commitTime, err := testCreate(r, WeetbixSystem)
+					So(err, ShouldBeNil)
+
+					expectedRule := *r
+					expectedRule.LastUpdated = commitTime
+					expectedRule.PredicateLastUpdated = commitTime
+					expectedRule.CreationTime = commitTime
+					testExists(expectedRule)
+				})
+				Convey(`With Monorail Bug`, func() {
+					r.BugID = bugs.BugID{System: "monorail", ID: "project/1234567890"}
+					commitTime, err := testCreate(r, WeetbixSystem)
+					So(err, ShouldBeNil)
+
+					expectedRule := *r
+					expectedRule.LastUpdated = commitTime
+					expectedRule.PredicateLastUpdated = commitTime
+					expectedRule.CreationTime = commitTime
+					testExists(expectedRule)
+				})
+			})
+			Convey(`With invalid Project`, func() {
+				Convey(`Missing`, func() {
+					r.Project = ""
+					_, err := testCreate(r, WeetbixSystem)
+					So(err, ShouldErrLike, "project must be valid")
+				})
+				Convey(`Invalid`, func() {
+					r.Project = "!"
+					_, err := testCreate(r, WeetbixSystem)
+					So(err, ShouldErrLike, "project must be valid")
+				})
+			})
+			Convey(`With invalid Rule Definition`, func() {
+				r.RuleDefinition = "invalid"
+				_, err := testCreate(r, WeetbixSystem)
+				So(err, ShouldErrLike, "rule definition is not valid")
+			})
+			Convey(`With invalid Bug ID`, func() {
+				r.BugID.System = ""
+				_, err := testCreate(r, WeetbixSystem)
+				So(err, ShouldErrLike, "bug ID is not valid")
+			})
+			Convey(`With invalid Source Cluster`, func() {
+				So(r.SourceCluster.ID, ShouldNotBeNil)
+				r.SourceCluster.Algorithm = ""
+				_, err := testCreate(r, WeetbixSystem)
+				So(err, ShouldErrLike, "source cluster ID is not valid")
+			})
+			Convey(`With invalid User`, func() {
+				_, err := testCreate(r, "")
+				So(err, ShouldErrLike, "user must be valid")
+			})
+		})
+		Convey(`Update`, func() {
+			testExists := func(expectedRule *FailureAssociationRule) {
+				txn, cancel := span.ReadOnlyTransaction(ctx)
+				defer cancel()
+				rule, err := Read(txn, expectedRule.Project, expectedRule.RuleID)
+				So(err, ShouldBeNil)
+				So(rule, ShouldResemble, expectedRule)
+			}
+			testUpdate := func(bc *FailureAssociationRule, predicateUpdated bool, user string) (time.Time, error) {
+				commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					return Update(ctx, bc, predicateUpdated, user)
+				})
+				return commitTime.In(time.UTC), err
+			}
+			r := NewRule(100).Build()
+			err := SetRulesForTesting(ctx, []*FailureAssociationRule{r})
+			So(err, ShouldBeNil)
+
+			Convey(`Valid`, func() {
+				Convey(`Update predicate`, func() {
+					r.RuleDefinition = `test = "UpdateTest"`
+					r.BugID = bugs.BugID{System: "monorail", ID: "chromium/651234"}
+					r.IsActive = false
+					r.SourceCluster = clustering.ClusterID{Algorithm: "testname-v1", ID: "00112233445566778899aabbccddeeff"}
+					updatePredicate := true
+					commitTime, err := testUpdate(r, updatePredicate, "testuser@google.com")
+					So(err, ShouldBeNil)
+
+					expectedRule := *r
+					expectedRule.PredicateLastUpdated = commitTime
+					expectedRule.LastUpdated = commitTime
+					expectedRule.LastUpdatedUser = "testuser@google.com"
+					testExists(&expectedRule)
+				})
+				Convey(`Do not update predicate`, func() {
+					r.BugID = bugs.BugID{System: "monorail", ID: "chromium/651234"}
+					r.SourceCluster = clustering.ClusterID{Algorithm: "testname-v1", ID: "00112233445566778899aabbccddeeff"}
+					updatePredicate := false
+					commitTime, err := testUpdate(r, updatePredicate, "testuser@google.com")
+					So(err, ShouldBeNil)
+
+					expectedRule := *r
+					expectedRule.LastUpdated = commitTime
+					expectedRule.LastUpdatedUser = "testuser@google.com"
+					testExists(&expectedRule)
+				})
+			})
+			Convey(`Invalid`, func() {
+				Convey(`With invalid User`, func() {
+					updatePredicate := false
+					_, err := testUpdate(r, updatePredicate, "")
+					So(err, ShouldErrLike, "user must be valid")
+				})
+				Convey(`With invalid Rule Definition`, func() {
+					r.RuleDefinition = "invalid"
+					updatePredicate := true
+					_, err := testUpdate(r, updatePredicate, WeetbixSystem)
+					So(err, ShouldErrLike, "rule definition is not valid")
+				})
+				Convey(`With invalid Bug ID`, func() {
+					r.BugID.System = ""
+					updatePredicate := false
+					_, err := testUpdate(r, updatePredicate, WeetbixSystem)
+					So(err, ShouldErrLike, "bug ID is not valid")
+				})
+				Convey(`With invalid Source Cluster`, func() {
+					So(r.SourceCluster.ID, ShouldNotBeNil)
+					r.SourceCluster.Algorithm = ""
+					updatePredicate := false
+					_, err := testUpdate(r, updatePredicate, WeetbixSystem)
+					So(err, ShouldErrLike, "source cluster ID is not valid")
+				})
+			})
+		})
+	})
+}
diff --git a/analysis/internal/clustering/rules/testutils.go b/analysis/internal/clustering/rules/testutils.go
new file mode 100644
index 0000000..66cc564
--- /dev/null
+++ b/analysis/internal/clustering/rules/testutils.go
@@ -0,0 +1,183 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rules
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/hex"
+	"fmt"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+const testProject = "myproject"
+
+// RuleBuilder provides methods to build a failure asociation rule
+// for testing.
+type RuleBuilder struct {
+	rule FailureAssociationRule
+}
+
+// NewRule starts building a new Rule.
+func NewRule(uniqifier int) *RuleBuilder {
+	ruleIDBytes := sha256.Sum256([]byte(fmt.Sprintf("rule-id%v", uniqifier)))
+	var bugID bugs.BugID
+	if uniqifier%2 == 0 {
+		bugID = bugs.BugID{System: "monorail", ID: fmt.Sprintf("chromium/%v", uniqifier)}
+	} else {
+		bugID = bugs.BugID{System: "buganizer", ID: fmt.Sprintf("%v", uniqifier)}
+	}
+
+	rule := FailureAssociationRule{
+		Project:              testProject,
+		RuleID:               hex.EncodeToString(ruleIDBytes[0:16]),
+		RuleDefinition:       "reason LIKE \"%exit code 5%\" AND test LIKE \"tast.arc.%\"",
+		BugID:                bugID,
+		IsActive:             true,
+		IsManagingBug:        true,
+		CreationTime:         time.Date(1900, 1, 2, 3, 4, 5, uniqifier, time.UTC),
+		CreationUser:         WeetbixSystem,
+		LastUpdated:          time.Date(1900, 1, 2, 3, 4, 7, uniqifier, time.UTC),
+		LastUpdatedUser:      "user@google.com",
+		PredicateLastUpdated: time.Date(1900, 1, 2, 3, 4, 6, uniqifier, time.UTC),
+		SourceCluster: clustering.ClusterID{
+			Algorithm: fmt.Sprintf("clusteralg%v-v9", uniqifier),
+			ID:        hex.EncodeToString([]byte(fmt.Sprintf("id%v", uniqifier))),
+		},
+	}
+	return &RuleBuilder{
+		rule: rule,
+	}
+}
+
+// WithProject specifies the project to use on the rule.
+func (b *RuleBuilder) WithProject(project string) *RuleBuilder {
+	b.rule.Project = project
+	return b
+}
+
+// WithRuleID specifies the Rule ID to use on the rule.
+func (b *RuleBuilder) WithRuleID(id string) *RuleBuilder {
+	b.rule.RuleID = id
+	return b
+}
+
+// WithActive specifies whether the rule will be active.
+func (b *RuleBuilder) WithActive(active bool) *RuleBuilder {
+	b.rule.IsActive = active
+	return b
+}
+
+// WithBugManaged specifies whether the rule's bug will be managed by Weetbix.
+func (b *RuleBuilder) WithBugManaged(value bool) *RuleBuilder {
+	b.rule.IsManagingBug = value
+	return b
+}
+
+// WithBug specifies the bug to use on the rule.
+func (b *RuleBuilder) WithBug(bug bugs.BugID) *RuleBuilder {
+	b.rule.BugID = bug
+	return b
+}
+
+// WithCreationTime specifies the creation time of the rule.
+func (b *RuleBuilder) WithCreationTime(value time.Time) *RuleBuilder {
+	b.rule.CreationTime = value
+	return b
+}
+
+// WithCreationUser specifies the "created" user on the rule.
+func (b *RuleBuilder) WithCreationUser(user string) *RuleBuilder {
+	b.rule.CreationUser = user
+	return b
+}
+
+// WithLastUpdated specifies the "last updated" time on the rule.
+func (b *RuleBuilder) WithLastUpdated(lastUpdated time.Time) *RuleBuilder {
+	b.rule.LastUpdated = lastUpdated
+	return b
+}
+
+// WithLastUpdatedUser specifies the "last updated" user on the rule.
+func (b *RuleBuilder) WithLastUpdatedUser(user string) *RuleBuilder {
+	b.rule.LastUpdatedUser = user
+	return b
+}
+
+// WithPredicateLastUpdated specifies the "predicate last updated" time on the rule.
+func (b *RuleBuilder) WithPredicateLastUpdated(value time.Time) *RuleBuilder {
+	b.rule.PredicateLastUpdated = value
+	return b
+}
+
+// WithRuleDefinition specifies the definition of the rule.
+func (b *RuleBuilder) WithRuleDefinition(definition string) *RuleBuilder {
+	b.rule.RuleDefinition = definition
+	return b
+}
+
+// WithSourceCluster specifies the source suggested cluster that triggered the
+// creation of the rule.
+func (b *RuleBuilder) WithSourceCluster(value clustering.ClusterID) *RuleBuilder {
+	b.rule.SourceCluster = value
+	return b
+}
+
+func (b *RuleBuilder) Build() *FailureAssociationRule {
+	// Copy the result, so that calling further methods on the builder does
+	// not change the returned rule.
+	result := new(FailureAssociationRule)
+	*result = b.rule
+	return result
+}
+
+// SetRulesForTesting replaces the set of stored rules to match the given set.
+func SetRulesForTesting(ctx context.Context, rs []*FailureAssociationRule) error {
+	testutil.MustApply(ctx,
+		spanner.Delete("FailureAssociationRules", spanner.AllKeys()))
+	// Insert some FailureAssociationRules.
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		for _, r := range rs {
+			ms := spanutil.InsertMap("FailureAssociationRules", map[string]interface{}{
+				"Project":              r.Project,
+				"RuleId":               r.RuleID,
+				"RuleDefinition":       r.RuleDefinition,
+				"CreationTime":         r.CreationTime,
+				"CreationUser":         r.CreationUser,
+				"LastUpdated":          r.LastUpdated,
+				"LastUpdatedUser":      r.LastUpdatedUser,
+				"BugSystem":            r.BugID.System,
+				"BugID":                r.BugID.ID,
+				"PredicateLastUpdated": r.PredicateLastUpdated,
+				// Uses the value 'NULL' to indicate false, and true to indicate true.
+				"IsActive":               spanner.NullBool{Bool: r.IsActive, Valid: r.IsActive},
+				"IsManagingBug":          spanner.NullBool{Bool: r.IsManagingBug, Valid: r.IsManagingBug},
+				"SourceClusterAlgorithm": r.SourceCluster.Algorithm,
+				"SourceClusterId":        r.SourceCluster.ID,
+			})
+			span.BufferWrite(ctx, ms)
+		}
+		return nil
+	})
+	return err
+}
diff --git a/analysis/internal/clustering/runs/main_test.go b/analysis/internal/clustering/runs/main_test.go
new file mode 100644
index 0000000..ddc9244
--- /dev/null
+++ b/analysis/internal/clustering/runs/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runs
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/runs/progress.go b/analysis/internal/clustering/runs/progress.go
new file mode 100644
index 0000000..9c5aaf4
--- /dev/null
+++ b/analysis/internal/clustering/runs/progress.go
@@ -0,0 +1,124 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runs
+
+import (
+	"context"
+	"time"
+
+	"go.chromium.org/luci/server/span"
+)
+
+// ReclusteringTarget captures the rules and algorithms a re-clustering run
+// is re-clustering to.
+type ReclusteringTarget struct {
+	// RulesVersion is the rules version the re-clustering run is attempting
+	// to achieve.
+	RulesVersion time.Time `json:"rulesVersion"`
+	// ConfigVersion is the config version the re-clustering run is attempting
+	// to achieve.
+	ConfigVersion time.Time `json:"configVersion"`
+	// AlgorithmsVersion is the algorithms version the re-clustering run is
+	// attempting to achieve.
+	AlgorithmsVersion int64 `json:"algorithmsVersion"`
+}
+
+// ReclusteringProgress captures the progress re-clustering a
+// given LUCI project's test results using specific rules
+// versions or algorithms versions.
+type ReclusteringProgress struct {
+	// ProgressPerMille is the progress of the current re-clustering run,
+	// measured in thousandths (per mille).
+	ProgressPerMille int `json:"progressPerMille"`
+	// Next is the goal of the current re-clustering run. (For which
+	// ProgressPerMille is specified.)
+	Next ReclusteringTarget `json:"next"`
+	// Last is the goal of the last completed re-clustering run.
+	Last ReclusteringTarget `json:"last"`
+}
+
+// ReadReclusteringProgress reads the re-clustering progress for
+// the given LUCI project.
+func ReadReclusteringProgress(ctx context.Context, project string) (*ReclusteringProgress, error) {
+	txn, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+
+	lastCompleted, err := ReadLastComplete(txn, project)
+	if err != nil {
+		return nil, err
+	}
+
+	lastWithProgress, err := ReadLastWithProgress(txn, project)
+	if err != nil {
+		return nil, err
+	}
+
+	last, err := ReadLast(txn, project)
+	if err != nil {
+		return nil, err
+	}
+
+	runProgress := 0
+	next := ReclusteringTarget{
+		RulesVersion:      last.RulesVersion,
+		ConfigVersion:     last.ConfigVersion,
+		AlgorithmsVersion: last.AlgorithmsVersion,
+	}
+
+	if last.RulesVersion.Equal(lastWithProgress.RulesVersion) &&
+		last.AlgorithmsVersion == lastWithProgress.AlgorithmsVersion &&
+		last.ConfigVersion.Equal(lastWithProgress.ConfigVersion) {
+		// Scale run progress to being from 0 to 1000.
+		runProgress = int(lastWithProgress.Progress / lastWithProgress.ShardCount)
+	}
+
+	return &ReclusteringProgress{
+		ProgressPerMille: runProgress,
+		Next:             next,
+		Last: ReclusteringTarget{
+			RulesVersion:      lastCompleted.RulesVersion,
+			ConfigVersion:     lastCompleted.ConfigVersion,
+			AlgorithmsVersion: lastCompleted.AlgorithmsVersion,
+		},
+	}, nil
+}
+
+// IsReclusteringToNewAlgorithms returns whether Weetbix's
+// clustering output is being updated to use a newer standard of
+// algorithms and is not yet stable. The algorithms version Weetbix
+// is re-clustering to is accessible via LatestAlgorithmsVersion.
+func (p *ReclusteringProgress) IsReclusteringToNewAlgorithms() bool {
+	return p.Last.AlgorithmsVersion < p.Next.AlgorithmsVersion
+}
+
+// IsReclusteringToNewConfig returns whether Weetbix's
+// clustering output is in the process of being updated to a later
+// configuration standard and is not yet stable.
+// The configuration version Weetbix is re-clustering to is accessible
+// via LatestConfigVersion.
+// Clients using re-clustering output should verify they are using
+// the configuration version defined by LatestConfigVersion when
+// interpreting the output.
+func (p *ReclusteringProgress) IsReclusteringToNewConfig() bool {
+	return p.Last.ConfigVersion.Before(p.Next.ConfigVersion)
+}
+
+// IncorporatesRulesVersion returns returns whether Weetbix
+// clustering output incorporates all rule changes up to
+// the given predicate last updated time. Later changes
+// may also be included, in full or in part.
+func (p *ReclusteringProgress) IncorporatesRulesVersion(rulesVersion time.Time) bool {
+	return !rulesVersion.After(p.Last.RulesVersion)
+}
diff --git a/analysis/internal/clustering/runs/progresstoken.go b/analysis/internal/clustering/runs/progresstoken.go
new file mode 100644
index 0000000..9aa4dc5
--- /dev/null
+++ b/analysis/internal/clustering/runs/progresstoken.go
@@ -0,0 +1,102 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runs
+
+import (
+	"context"
+	"errors"
+	"time"
+
+	"go.chromium.org/luci/server/span"
+)
+
+// ProgressToken is used to report the progress reported for a shard.
+// To avoid double-counting progress, the progress state must be
+// saved and restored between tasks that report for the shard.
+type ProgressToken struct {
+	project              string
+	attemptTimestamp     time.Time
+	reportedOnce         bool
+	lastReportedProgress int
+	invalid              bool
+}
+
+// ProgressState encapsulates the reporting state of a progress token.
+// The reporting state avoids a shard double-reporting progress.
+type ProgressState struct {
+	// ReportedOnce is whether any progress has been reported for
+	// the shard.
+	ReportedOnce bool
+	// LastReportedProgress is the last reported progress for the
+	// shard.
+	LastReportedProgress int
+}
+
+// NewProgressToken initialises a new progress token with the given LUCI
+// project ID, attempt key and state.
+func NewProgressToken(project string, attemptTimestamp time.Time, state *ProgressState) *ProgressToken {
+	return &ProgressToken{
+		project:              project,
+		attemptTimestamp:     attemptTimestamp,
+		reportedOnce:         state.ReportedOnce,
+		lastReportedProgress: state.LastReportedProgress,
+	}
+}
+
+// ExportState exports the state of the progress token. After the export,
+// the token is invalidated and cannot be used to report progress anymore.
+func (p *ProgressToken) ExportState() (*ProgressState, error) {
+	if p.invalid {
+		return nil, errors.New("state cannot be exported; token is invalid")
+	}
+	p.invalid = true
+	return &ProgressState{
+		ReportedOnce:         p.reportedOnce,
+		LastReportedProgress: p.lastReportedProgress,
+	}, nil
+}
+
+// ReportProgress reports the progress for the current shard. Progress ranges
+// from 0 to 1000, with 1000 indicating the all work assigned to the shard
+// is complete.
+func (p *ProgressToken) ReportProgress(ctx context.Context, value int) error {
+	if p.invalid {
+		return errors.New("no more progress can be reported; token is invalid")
+	}
+	// Bound progress values to the allowed range.
+	if value < 0 || value > 1000 {
+		return errors.New("progress value must be between 0 and 1000")
+	}
+	if p.reportedOnce && p.lastReportedProgress == value {
+		// Progress did not change, nothing to do.
+		return nil
+	}
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		deltaProgress := value - p.lastReportedProgress
+		err := reportProgress(ctx, p.project, p.attemptTimestamp, !p.reportedOnce, deltaProgress)
+		return err
+	})
+	if err != nil {
+		// If we get an error back, we are not sure if the transaction
+		// failed to commit, or if it did commit but our connection to
+		// Spanner dropped. We should treat the token as invalid and
+		// not report any more progress for this shard.
+		p.invalid = true
+		return err
+	}
+	p.reportedOnce = true
+	p.lastReportedProgress = value
+	return nil
+}
diff --git a/analysis/internal/clustering/runs/span.go b/analysis/internal/clustering/runs/span.go
new file mode 100644
index 0000000..7c689f1
--- /dev/null
+++ b/analysis/internal/clustering/runs/span.go
@@ -0,0 +1,254 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runs
+
+import (
+	"context"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+)
+
+// ReclusteringRun contains the details of a runs used to re-cluster
+// test results.
+type ReclusteringRun struct {
+	// The LUCI Project for which this rule is defined.
+	Project string
+	// The attempt. This is the time the orchestrator run ends.
+	AttemptTimestamp time.Time
+	// The minimum algorithms version this reclustering run is trying
+	// to achieve. Chunks with an AlgorithmsVersion less than this
+	// value are eligible to be re-clustered.
+	AlgorithmsVersion int64
+	// The minimum config version the reclustering run is trying to achieve.
+	// Chunks with a ConfigVersion less than this value are eligible to be
+	// re-clustered.
+	ConfigVersion time.Time
+	// The minimum rules version the reclustering run is trying to achieve.
+	// Chunks with a RulesVersion less than this value are eligible to be
+	// re-clustered.
+	RulesVersion time.Time
+	// The number of shards created for this run (for this LUCI project).
+	ShardCount int64
+	// The number of shards that have reported progress (at least once).
+	// When this is equal to ShardCount, readers can have confidence Progress
+	// is a reasonable reflection of the progress made reclustering
+	// this project. Until then, it is a loose lower-bound.
+	ShardsReported int64
+	// The progress. This is a value between 0 and 1000*ShardCount.
+	Progress int64
+}
+
+// NotFound is the error returned by Read if the row could not be found.
+var NotFound = errors.New("reclustering run row not found")
+
+// StartingEpoch is the earliest valid run attempt time.
+var StartingEpoch = time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC)
+
+// Read reads the run with the given attempt timestamp in the given LUCI
+// project. If the row does not exist, the error NotFound is returned.
+func Read(ctx context.Context, projectID string, attemptTimestamp time.Time) (*ReclusteringRun, error) {
+	whereClause := `AttemptTimestamp = @attemptTimestamp`
+	params := map[string]interface{}{
+		"attemptTimestamp": attemptTimestamp,
+	}
+	r, err := readLastWhere(ctx, projectID, whereClause, params)
+	if err != nil {
+		return nil, errors.Annotate(err, "query run").Err()
+	}
+	if r == nil {
+		return nil, NotFound
+	}
+	return r, nil
+}
+
+// ReadLast reads the last run in the given LUCI project. If no row exists,
+// a fake run is returned with the following details:
+// - Project matching the requested Project ID.
+// - AttemptTimestamp of StartingEpoch.
+// - AlgorithmsVersion of 1.
+// - ConfigVersion of clusteringcfg.StartingEpoch.
+// - RulesVersion of rules.StartingEpoch.
+// - ShardCount and ShardsReported of 1.
+// - Progress of 1000.
+func ReadLast(ctx context.Context, projectID string) (*ReclusteringRun, error) {
+	whereClause := `TRUE`
+	r, err := readLastWhere(ctx, projectID, whereClause, nil)
+	if err != nil {
+		return nil, errors.Annotate(err, "query last run").Err()
+	}
+	if r == nil {
+		r = fakeLastRow(projectID)
+	}
+	return r, nil
+}
+
+// ReadLastWithProgress reads the last run with progress in the given LUCI
+// project. If no row exists, a fake row is returned; see ReadLast for details.
+func ReadLastWithProgress(ctx context.Context, projectID string) (*ReclusteringRun, error) {
+	whereClause := `ShardsReported = ShardCount`
+	r, err := readLastWhere(ctx, projectID, whereClause, nil)
+	if err != nil {
+		return nil, errors.Annotate(err, "query last run").Err()
+	}
+	if r == nil {
+		r = fakeLastRow(projectID)
+	}
+	return r, nil
+}
+
+// ReadLastComplete reads the last run that completed in the given LUCI
+// project. If no row exists, a fake row is returned; see ReadLast for details.
+func ReadLastComplete(ctx context.Context, projectID string) (*ReclusteringRun, error) {
+	whereClause := `Progress = (ShardCount * 1000)`
+	r, err := readLastWhere(ctx, projectID, whereClause, nil)
+	if err != nil {
+		return nil, errors.Annotate(err, "query last run").Err()
+	}
+	if r == nil {
+		r = fakeLastRow(projectID)
+	}
+	return r, nil
+}
+
+func fakeLastRow(projectID string) *ReclusteringRun {
+	return &ReclusteringRun{
+		Project:           projectID,
+		AttemptTimestamp:  StartingEpoch,
+		AlgorithmsVersion: 1,
+		ConfigVersion:     config.StartingEpoch,
+		RulesVersion:      rules.StartingEpoch,
+		ShardCount:        1,
+		ShardsReported:    1,
+		Progress:          1000,
+	}
+}
+
+// readLastWhere reads the last run matching the given where clause,
+// substituting params for any SQL parameters used in that clause.
+func readLastWhere(ctx context.Context, projectID string, whereClause string, params map[string]interface{}) (*ReclusteringRun, error) {
+	stmt := spanner.NewStatement(`
+		SELECT
+		  AttemptTimestamp, ConfigVersion, RulesVersion,
+		  AlgorithmsVersion, ShardCount, ShardsReported, Progress
+		FROM ReclusteringRuns
+		WHERE Project = @projectID AND (` + whereClause + `)
+		ORDER BY AttemptTimestamp DESC
+		LIMIT 1
+	`)
+	for k, v := range params {
+		stmt.Params[k] = v
+	}
+	stmt.Params["projectID"] = projectID
+
+	it := span.Query(ctx, stmt)
+	rs := []*ReclusteringRun{}
+	err := it.Do(func(r *spanner.Row) error {
+		var attemptTimestamp, rulesVersion, configVersion time.Time
+		var algorithmsVersion, shardCount, shardsReported, progress int64
+		err := r.Columns(
+			&attemptTimestamp, &configVersion, &rulesVersion,
+			&algorithmsVersion, &shardCount, &shardsReported, &progress,
+		)
+		if err != nil {
+			return errors.Annotate(err, "read run row").Err()
+		}
+
+		run := &ReclusteringRun{
+			Project:           projectID,
+			AttemptTimestamp:  attemptTimestamp,
+			AlgorithmsVersion: algorithmsVersion,
+			ConfigVersion:     configVersion,
+			RulesVersion:      rulesVersion,
+			ShardCount:        shardCount,
+			ShardsReported:    shardsReported,
+			Progress:          progress,
+		}
+		rs = append(rs, run)
+		return nil
+	})
+	if len(rs) > 0 {
+		return rs[0], err
+	}
+	return nil, err
+}
+
+// Create inserts a new reclustering run.
+func Create(ctx context.Context, r *ReclusteringRun) error {
+	if err := validateRun(r); err != nil {
+		return err
+	}
+	ms := spanutil.InsertMap("ReclusteringRuns", map[string]interface{}{
+		"Project":           r.Project,
+		"AttemptTimestamp":  r.AttemptTimestamp,
+		"AlgorithmsVersion": r.AlgorithmsVersion,
+		"ConfigVersion":     r.ConfigVersion,
+		"RulesVersion":      r.RulesVersion,
+		"ShardCount":        r.ShardCount,
+		"ShardsReported":    r.ShardsReported,
+		"Progress":          r.Progress,
+	})
+	span.BufferWrite(ctx, ms)
+	return nil
+}
+
+func validateRun(r *ReclusteringRun) error {
+	switch {
+	case !config.ProjectRe.MatchString(r.Project):
+		return errors.New("project must be valid")
+	case r.AttemptTimestamp.Before(StartingEpoch):
+		return errors.New("attempt timestamp must be valid")
+	case r.AlgorithmsVersion <= 0:
+		return errors.New("algorithms version must be valid")
+	case r.ConfigVersion.Before(config.StartingEpoch):
+		return errors.New("config version must be valid")
+	case r.RulesVersion.Before(rules.StartingEpoch):
+		return errors.New("rules version must be valid")
+	case r.ShardCount <= 0:
+		return errors.New("shard count must be valid")
+	case r.ShardsReported < 0 || r.ShardsReported > r.ShardCount:
+		return errors.New("shards reported must be valid")
+	case r.Progress < 0 || r.Progress > (r.ShardCount*1000):
+		return errors.New("progress must be valid")
+	}
+	return nil
+}
+
+// reportProgress adds progress to a particular run. To ensure correct
+// usage, this should only be called from ProgressToken.
+func reportProgress(ctx context.Context, projectID string, attemptTimestamp time.Time, firstReport bool, deltaProgress int) error {
+	stmt := spanner.NewStatement(`
+	  UPDATE ReclusteringRuns
+	  SET ShardsReported = ShardsReported + @deltaShardsReported,
+	      Progress = Progress + @deltaProgress
+	  WHERE Project = @projectID AND AttemptTimestamp = @attemptTimestamp
+	`)
+	deltaShardsReported := 0
+	if firstReport {
+		deltaShardsReported = 1
+	}
+	stmt.Params["deltaShardsReported"] = deltaShardsReported
+	stmt.Params["deltaProgress"] = deltaProgress
+	stmt.Params["projectID"] = projectID
+	stmt.Params["attemptTimestamp"] = attemptTimestamp
+	_, err := span.Update(ctx, stmt)
+	return err
+}
diff --git a/analysis/internal/clustering/runs/span_test.go b/analysis/internal/clustering/runs/span_test.go
new file mode 100644
index 0000000..62dabb9
--- /dev/null
+++ b/analysis/internal/clustering/runs/span_test.go
@@ -0,0 +1,318 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runs
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestSpan(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		Convey(`Reads`, func() {
+			reference := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+			runs := []*ReclusteringRun{
+				NewRun(0).WithProject("otherproject").WithAttemptTimestamp(reference).WithCompletedProgress().Build(),
+				NewRun(1).WithAttemptTimestamp(reference.Add(-5 * time.Minute)).Build(),
+				NewRun(2).WithAttemptTimestamp(reference.Add(-10 * time.Minute)).Build(),
+				NewRun(3).WithAttemptTimestamp(reference.Add(-20 * time.Minute)).WithReportedProgress(500).Build(),
+				NewRun(4).WithAttemptTimestamp(reference.Add(-30 * time.Minute)).WithReportedProgress(500).Build(),
+				NewRun(5).WithAttemptTimestamp(reference.Add(-40 * time.Minute)).WithCompletedProgress().Build(),
+				NewRun(6).WithAttemptTimestamp(reference.Add(-50 * time.Minute)).WithCompletedProgress().Build(),
+			}
+			err := SetRunsForTesting(ctx, runs)
+			So(err, ShouldBeNil)
+
+			// For ReadLast... methods, this is the fake row that is expected
+			// to be returned if no row exists.
+			expectedFake := &ReclusteringRun{
+				Project:           "emptyproject",
+				AttemptTimestamp:  time.Date(1900, 1, 1, 0, 0, 0, 0, time.UTC),
+				AlgorithmsVersion: 1,
+				ConfigVersion:     config.StartingEpoch,
+				RulesVersion:      rules.StartingEpoch,
+				ShardCount:        1,
+				ShardsReported:    1,
+				Progress:          1000,
+			}
+
+			Convey(`Read`, func() {
+				Convey(`Not Exists`, func() {
+					run, err := Read(span.Single(ctx), testProject, reference)
+					So(err, ShouldEqual, NotFound)
+					So(run, ShouldBeNil)
+				})
+				Convey(`Exists`, func() {
+					run, err := Read(span.Single(ctx), testProject, runs[2].AttemptTimestamp)
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, runs[2])
+				})
+			})
+			Convey(`ReadLast`, func() {
+				Convey(`Not Exists`, func() {
+					run, err := ReadLast(span.Single(ctx), "emptyproject")
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, expectedFake)
+				})
+				Convey(`Exists`, func() {
+					run, err := ReadLast(span.Single(ctx), testProject)
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, runs[1])
+				})
+			})
+			Convey(`ReadLastWithProgress`, func() {
+				Convey(`Not Exists`, func() {
+					run, err := ReadLastWithProgress(span.Single(ctx), "emptyproject")
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, expectedFake)
+				})
+				Convey(`Exists`, func() {
+					run, err := ReadLastWithProgress(span.Single(ctx), testProject)
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, runs[3])
+				})
+			})
+			Convey(`ReadLastComplete`, func() {
+				Convey(`Not Exists`, func() {
+					run, err := ReadLastComplete(span.Single(ctx), "emptyproject")
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, expectedFake)
+				})
+				Convey(`Exists`, func() {
+					run, err := ReadLastComplete(span.Single(ctx), testProject)
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, runs[5])
+				})
+			})
+		})
+		Convey(`Query Progress`, func() {
+			Convey(`Rule Progress`, func() {
+				rulesVersion := time.Date(2021, time.January, 1, 1, 0, 0, 0, time.UTC)
+
+				reference := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+				runs := []*ReclusteringRun{
+					NewRun(0).WithAttemptTimestamp(reference.Add(-5 * time.Minute)).WithRulesVersion(rulesVersion).WithNoReportedProgress().Build(),
+					NewRun(1).WithAttemptTimestamp(reference.Add(-10 * time.Minute)).WithRulesVersion(rulesVersion).WithReportedProgress(500).Build(),
+					NewRun(2).WithAttemptTimestamp(reference.Add(-20 * time.Minute)).WithRulesVersion(rulesVersion.Add(-1 * time.Hour)).WithCompletedProgress().Build(),
+				}
+				err := SetRunsForTesting(ctx, runs)
+				So(err, ShouldBeNil)
+
+				progress, err := ReadReclusteringProgress(ctx, testProject)
+				So(err, ShouldBeNil)
+
+				So(progress.IncorporatesRulesVersion(rulesVersion.Add(1*time.Hour)), ShouldBeFalse)
+				So(progress.IncorporatesRulesVersion(rulesVersion), ShouldBeFalse)
+				So(progress.IncorporatesRulesVersion(rulesVersion.Add(-1*time.Minute)), ShouldBeFalse)
+				So(progress.IncorporatesRulesVersion(rulesVersion.Add(-1*time.Hour)), ShouldBeTrue)
+				So(progress.IncorporatesRulesVersion(rulesVersion.Add(-2*time.Hour)), ShouldBeTrue)
+			})
+			Convey(`Algorithms Upgrading`, func() {
+				reference := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+				runs := []*ReclusteringRun{
+					NewRun(0).WithAttemptTimestamp(reference.Add(-5 * time.Minute)).WithAlgorithmsVersion(algorithms.AlgorithmsVersion + 1).WithNoReportedProgress().Build(),
+					NewRun(1).WithAttemptTimestamp(reference.Add(-10 * time.Minute)).WithAlgorithmsVersion(algorithms.AlgorithmsVersion + 1).WithReportedProgress(500).Build(),
+					NewRun(2).WithAttemptTimestamp(reference.Add(-20 * time.Minute)).WithAlgorithmsVersion(algorithms.AlgorithmsVersion).WithCompletedProgress().Build(),
+				}
+				err := SetRunsForTesting(ctx, runs)
+				So(err, ShouldBeNil)
+
+				progress, err := ReadReclusteringProgress(ctx, testProject)
+				So(err, ShouldBeNil)
+
+				So(progress.Next.AlgorithmsVersion, ShouldEqual, algorithms.AlgorithmsVersion+1)
+				So(progress.IsReclusteringToNewAlgorithms(), ShouldBeTrue)
+			})
+			Convey(`Config Upgrading`, func() {
+				configVersion := time.Date(2025, time.January, 1, 1, 0, 0, 0, time.UTC)
+
+				reference := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+				runs := []*ReclusteringRun{
+					NewRun(0).WithAttemptTimestamp(reference.Add(-5 * time.Minute)).WithConfigVersion(configVersion).WithNoReportedProgress().Build(),
+					NewRun(1).WithAttemptTimestamp(reference.Add(-10 * time.Minute)).WithConfigVersion(configVersion).WithReportedProgress(500).Build(),
+					NewRun(2).WithAttemptTimestamp(reference.Add(-20 * time.Minute)).WithConfigVersion(configVersion.Add(-1 * time.Hour)).WithCompletedProgress().Build(),
+				}
+				err := SetRunsForTesting(ctx, runs)
+				So(err, ShouldBeNil)
+
+				progress, err := ReadReclusteringProgress(ctx, testProject)
+				So(err, ShouldBeNil)
+
+				So(progress.Next.ConfigVersion, ShouldEqual, configVersion)
+				So(progress.IsReclusteringToNewConfig(), ShouldBeTrue)
+			})
+			Convey(`Algorithms and Config Stable`, func() {
+				reference := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+				configVersion := time.Date(2025, time.January, 1, 1, 0, 0, 0, time.UTC)
+				algVersion := int64(2)
+				runs := []*ReclusteringRun{
+					NewRun(0).WithAttemptTimestamp(reference.Add(-5 * time.Minute)).WithAlgorithmsVersion(algVersion).WithConfigVersion(configVersion).WithNoReportedProgress().Build(),
+					NewRun(1).WithAttemptTimestamp(reference.Add(-10 * time.Minute)).WithAlgorithmsVersion(algVersion).WithConfigVersion(configVersion).WithReportedProgress(500).Build(),
+					NewRun(2).WithAttemptTimestamp(reference.Add(-20 * time.Minute)).WithAlgorithmsVersion(algVersion).WithConfigVersion(configVersion).WithCompletedProgress().Build(),
+				}
+				err := SetRunsForTesting(ctx, runs)
+				So(err, ShouldBeNil)
+
+				progress, err := ReadReclusteringProgress(ctx, testProject)
+				So(err, ShouldBeNil)
+
+				So(progress.Next.AlgorithmsVersion, ShouldEqual, algVersion)
+				So(progress.Next.ConfigVersion, ShouldEqual, configVersion)
+				So(progress.IsReclusteringToNewAlgorithms(), ShouldBeFalse)
+				So(progress.IsReclusteringToNewConfig(), ShouldBeFalse)
+			})
+		})
+		Convey(`Reporting Progress`, func() {
+			reference := time.Date(2020, time.January, 1, 1, 0, 0, 0, time.UTC)
+			assertProgress := func(shardsReported, progress int64) {
+				run, err := Read(span.Single(ctx), testProject, reference)
+				So(err, ShouldBeNil)
+				So(run.ShardsReported, ShouldEqual, shardsReported)
+				So(run.Progress, ShouldEqual, progress)
+			}
+
+			runs := []*ReclusteringRun{
+				NewRun(0).WithAttemptTimestamp(reference).WithShardCount(2).WithNoReportedProgress().Build(),
+			}
+			err := SetRunsForTesting(ctx, runs)
+			So(err, ShouldBeNil)
+
+			Convey(`Concurrent`, func() {
+				token1 := NewProgressToken(testProject, reference, &ProgressState{})
+				token2 := NewProgressToken(testProject, reference, &ProgressState{})
+				assertProgress(0, 0)
+
+				So(token1.ReportProgress(ctx, 0), ShouldBeNil)
+				assertProgress(1, 0)
+
+				So(token1.ReportProgress(ctx, 150), ShouldBeNil)
+				assertProgress(1, 150)
+
+				So(token2.ReportProgress(ctx, 200), ShouldBeNil)
+				assertProgress(2, 350)
+
+				So(token1.ReportProgress(ctx, 200), ShouldBeNil)
+				assertProgress(2, 400)
+
+				So(token2.ReportProgress(ctx, 1000), ShouldBeNil)
+				assertProgress(2, 1200)
+
+				So(token1.ReportProgress(ctx, 1000), ShouldBeNil)
+				assertProgress(2, 2000)
+			})
+			Convey(`Export State`, func() {
+				token := NewProgressToken(testProject, reference, &ProgressState{})
+				assertProgress(0, 0)
+
+				state, err := token.ExportState()
+				So(err, ShouldBeNil)
+				So(state, ShouldResemble, &ProgressState{ReportedOnce: false, LastReportedProgress: 0})
+
+				token = NewProgressToken(testProject, reference, state)
+				So(token.ReportProgress(ctx, 150), ShouldBeNil)
+				assertProgress(1, 150)
+
+				state, err = token.ExportState()
+				So(err, ShouldBeNil)
+				So(state, ShouldResemble, &ProgressState{ReportedOnce: true, LastReportedProgress: 150})
+
+				token = NewProgressToken(testProject, reference, state)
+				So(token.ReportProgress(ctx, 1000), ShouldBeNil)
+				assertProgress(1, 1000)
+
+				state, err = token.ExportState()
+				So(err, ShouldBeNil)
+				So(state, ShouldResemble, &ProgressState{ReportedOnce: true, LastReportedProgress: 1000})
+			})
+		})
+		Convey(`Create`, func() {
+			testCreate := func(bc *ReclusteringRun) error {
+				_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					return Create(ctx, bc)
+				})
+				return err
+			}
+			r := NewRun(100).Build()
+			Convey(`Valid`, func() {
+				testExists := func(expectedRun *ReclusteringRun) {
+					txn, cancel := span.ReadOnlyTransaction(ctx)
+					defer cancel()
+					run, err := Read(txn, expectedRun.Project, expectedRun.AttemptTimestamp)
+
+					So(err, ShouldBeNil)
+					So(run, ShouldResemble, expectedRun)
+				}
+
+				err := testCreate(r)
+				So(err, ShouldBeNil)
+				testExists(r)
+			})
+			Convey(`With invalid Project`, func() {
+				Convey(`Missing`, func() {
+					r.Project = ""
+					err := testCreate(r)
+					So(err, ShouldErrLike, "project must be valid")
+				})
+				Convey(`Invalid`, func() {
+					r.Project = "!"
+					err := testCreate(r)
+					So(err, ShouldErrLike, "project must be valid")
+				})
+			})
+			Convey(`With invalid Attempt Timestamp`, func() {
+				r.AttemptTimestamp = time.Time{}
+				err := testCreate(r)
+				So(err, ShouldErrLike, "attempt timestamp must be valid")
+			})
+			Convey(`With invalid Algorithms Version`, func() {
+				r.AlgorithmsVersion = 0
+				err := testCreate(r)
+				So(err, ShouldErrLike, "algorithms version must be valid")
+			})
+			Convey(`With invalid Rules Version`, func() {
+				r.RulesVersion = time.Time{}
+				err := testCreate(r)
+				So(err, ShouldErrLike, "rules version must be valid")
+			})
+			Convey(`With invalid Shard Count`, func() {
+				r.ShardCount = 0
+				err := testCreate(r)
+				So(err, ShouldErrLike, "shard count must be valid")
+			})
+			Convey(`With invalid Shards Reported`, func() {
+				r.ShardsReported = r.ShardCount + 1
+				err := testCreate(r)
+				So(err, ShouldErrLike, "shards reported must be valid")
+			})
+			Convey(`With invalid Progress`, func() {
+				r.Progress = r.ShardCount*1000 + 1
+				err := testCreate(r)
+				So(err, ShouldErrLike, "progress must be valid")
+			})
+		})
+	})
+}
diff --git a/analysis/internal/clustering/runs/testutils.go b/analysis/internal/clustering/runs/testutils.go
new file mode 100644
index 0000000..1019b4e
--- /dev/null
+++ b/analysis/internal/clustering/runs/testutils.go
@@ -0,0 +1,139 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package runs
+
+import (
+	"context"
+	"time"
+
+	"cloud.google.com/go/spanner"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+
+	"go.chromium.org/luci/server/span"
+)
+
+const testProject = "myproject"
+
+// RunBuilder provides methods to build a reclustering run
+// for testing.
+type RunBuilder struct {
+	run         *ReclusteringRun
+	progressSet bool
+}
+
+// NewRun starts building a new Run, for testing.
+func NewRun(uniqifier int) *RunBuilder {
+	run := &ReclusteringRun{
+		Project:           testProject,
+		AttemptTimestamp:  time.Date(2010, time.January, 1, 1, 0, 0, uniqifier, time.UTC),
+		AlgorithmsVersion: int64(uniqifier + 1),
+		ConfigVersion:     time.Date(2011, time.January, 1, 1, 0, 0, uniqifier, time.UTC),
+		RulesVersion:      time.Date(2012, time.January, 1, 1, 0, 0, uniqifier, time.UTC),
+		ShardCount:        int64(uniqifier + 1),
+		ShardsReported:    int64(uniqifier / 2),
+		Progress:          int64(uniqifier) * 500,
+	}
+	return &RunBuilder{
+		run: run,
+	}
+}
+
+// WithProject specifies the project to use on the run.
+func (b *RunBuilder) WithProject(project string) *RunBuilder {
+	b.run.Project = project
+	return b
+}
+
+// WithAttemptTimestamp specifies the attempt timestamp to use on the run.
+func (b *RunBuilder) WithAttemptTimestamp(attemptTimestamp time.Time) *RunBuilder {
+	b.run.AttemptTimestamp = attemptTimestamp
+	return b
+}
+
+// WithRulesVersion specifies the rules version to use on the run.
+func (b *RunBuilder) WithRulesVersion(value time.Time) *RunBuilder {
+	b.run.RulesVersion = value
+	return b
+}
+
+// WithRulesVersion specifies the config version to use on the run.
+func (b *RunBuilder) WithConfigVersion(value time.Time) *RunBuilder {
+	b.run.ConfigVersion = value
+	return b
+}
+
+// WithAlgorithmsVersion specifies the algorithms version to use on the run.
+func (b *RunBuilder) WithAlgorithmsVersion(value int64) *RunBuilder {
+	b.run.AlgorithmsVersion = value
+	return b
+}
+
+// WithShardCount specifies the number of shards to use on the run.
+func (b *RunBuilder) WithShardCount(count int64) *RunBuilder {
+	if b.progressSet {
+		panic("Call WithShardCount before setting progress")
+	}
+	b.run.ShardCount = count
+	b.run.Progress = count * 500
+	return b
+}
+
+// WithNoProgress sets that no shards reported and no progress has been made.
+func (b *RunBuilder) WithNoReportedProgress() *RunBuilder {
+	b.run.ShardsReported = 0
+	b.run.Progress = 0
+	b.progressSet = true
+	return b
+}
+
+// WithReportedProgress sets that all shards have reported, and some progress
+// has been made. (progress is a value out of 1000 that is scaled to the
+// number of shards).
+func (b *RunBuilder) WithReportedProgress(progress int) *RunBuilder {
+	b.run.ShardsReported = b.run.ShardCount
+	b.run.Progress = b.run.ShardCount * int64(progress)
+	b.progressSet = true
+	return b
+}
+
+// WithCompletedProgress sets that all shards have reported, and the run
+// has completed.
+func (b *RunBuilder) WithCompletedProgress() *RunBuilder {
+	b.run.ShardsReported = b.run.ShardCount
+	b.run.Progress = b.run.ShardCount * 1000
+	b.progressSet = true
+	return b
+}
+
+func (b *RunBuilder) Build() *ReclusteringRun {
+	return b.run
+}
+
+// SetRunsForTesting replaces the set of stored runs to match the given set.
+func SetRunsForTesting(ctx context.Context, rs []*ReclusteringRun) error {
+	testutil.MustApply(ctx,
+		spanner.Delete("ReclusteringRuns", spanner.AllKeys()))
+	// Insert some ReclusteringRuns.
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		for _, r := range rs {
+			if err := Create(ctx, r); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+	return err
+}
diff --git a/analysis/internal/clustering/state/encoding.go b/analysis/internal/clustering/state/encoding.go
new file mode 100644
index 0000000..b8d037a
--- /dev/null
+++ b/analysis/internal/clustering/state/encoding.go
@@ -0,0 +1,154 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package state
+
+import (
+	"encoding/hex"
+	"fmt"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+
+	"go.chromium.org/luci/common/errors"
+)
+
+// decodeClusters decodes:
+// - the set of algorithms used for clustering, and
+// - the clusters assigned to each test result
+// from the protobuf representation.
+func decodeClusters(cc *cpb.ChunkClusters) (map[string]struct{}, [][]clustering.ClusterID, error) {
+	if cc == nil {
+		return nil, nil, errors.New("proto must be specified")
+	}
+	typeCount := int64(len(cc.ClusterTypes))
+	clusterCount := int64(len(cc.ReferencedClusters))
+
+	algorithms := make(map[string]struct{})
+	for _, ct := range cc.ClusterTypes {
+		algorithms[ct.Algorithm] = struct{}{}
+	}
+
+	clusterIDs := make([][]clustering.ClusterID, len(cc.ResultClusters))
+	for i, rc := range cc.ResultClusters {
+		// For each test result.
+		clusters := make([]clustering.ClusterID, len(rc.ClusterRefs))
+		for j, ref := range rc.ClusterRefs {
+			// Decode each reference to a cluster ID.
+			if ref < 0 || ref >= clusterCount {
+				return nil, nil, fmt.Errorf("reference to non-existent cluster (%v) from result %v; only %v referenced clusters defined", ref, i, clusterCount)
+			}
+			cluster := cc.ReferencedClusters[ref]
+			if cluster.TypeRef < 0 || cluster.TypeRef >= typeCount {
+				return nil, nil, fmt.Errorf("reference to non-existent type (%v) from referenced cluster %v; only %v types defined", cluster.TypeRef, ref, typeCount)
+			}
+			t := cc.ClusterTypes[cluster.TypeRef]
+			clusters[j] = clustering.ClusterID{
+				Algorithm: t.Algorithm,
+				ID:        hex.EncodeToString(cluster.ClusterId),
+			}
+		}
+		clusterIDs[i] = clusters
+	}
+	return algorithms, clusterIDs, nil
+}
+
+// encodeClusters encodes:
+// - the set of algorithms used for clustering, and
+// - the clusters assigned to each test result
+// to the protobuf representation.
+func encodeClusters(algorithms map[string]struct{}, clusterIDs [][]clustering.ClusterID) (*cpb.ChunkClusters, error) {
+	rb := newRefBuilder()
+	for a := range algorithms {
+		rb.registerClusterType(a)
+	}
+
+	resultClusters := make([]*cpb.TestResultClusters, len(clusterIDs))
+	for i, ids := range clusterIDs {
+		clusters := &cpb.TestResultClusters{}
+		clusters.ClusterRefs = make([]int64, len(ids))
+		for j, id := range ids {
+			clusterRef, err := rb.referenceCluster(id)
+			if err != nil {
+				return nil, errors.Annotate(err, "cluster ID %s/%s is invalid", id.Algorithm, id.ID).Err()
+			}
+			clusters.ClusterRefs[j] = clusterRef
+		}
+		resultClusters[i] = clusters
+	}
+	result := &cpb.ChunkClusters{
+		ClusterTypes:       rb.types,
+		ReferencedClusters: rb.refs,
+		ResultClusters:     resultClusters,
+	}
+	return result, nil
+}
+
+// refBuilder assists in constructing the type and cluster references used in
+// the proto representation.
+type refBuilder struct {
+	types []*cpb.ClusterType
+	// typeMap is a mapping from algorithm name to the index in types.
+	typeMap map[string]int
+	refs    []*cpb.ReferencedCluster
+	// refMap is a mapping from (algorithm name, cluster ID) to the
+	// the corresponding cluster reference in refs.
+	refMap map[string]int
+}
+
+func newRefBuilder() *refBuilder {
+	return &refBuilder{
+		typeMap: make(map[string]int),
+		refMap:  make(map[string]int),
+	}
+}
+
+func (rb *refBuilder) referenceCluster(ref clustering.ClusterID) (int64, error) {
+	refKey := ref.Key()
+	idx, ok := rb.refMap[refKey]
+	if !ok {
+		// Convert from hexadecimal to byte representation, for storage
+		// efficiency.
+		id, err := hex.DecodeString(ref.ID)
+		if err != nil {
+			return -1, err
+		}
+		typeRef, err := rb.referenceClusterType(ref.Algorithm)
+		if err != nil {
+			return -1, err
+		}
+		ref := &cpb.ReferencedCluster{
+			TypeRef:   typeRef,
+			ClusterId: id,
+		}
+		idx = len(rb.refs)
+		rb.refMap[refKey] = idx
+		rb.refs = append(rb.refs, ref)
+	}
+	return int64(idx), nil
+}
+
+func (rb *refBuilder) referenceClusterType(algorithm string) (int64, error) {
+	idx, ok := rb.typeMap[algorithm]
+	if !ok {
+		return -1, fmt.Errorf("a test result was clustered with an unregistered algorithm: %s", algorithm)
+	}
+	return int64(idx), nil
+}
+
+func (rb *refBuilder) registerClusterType(algorithm string) {
+	idx := len(rb.types)
+	rb.types = append(rb.types, &cpb.ClusterType{Algorithm: algorithm})
+	rb.typeMap[algorithm] = idx
+}
diff --git a/analysis/internal/clustering/state/main_test.go b/analysis/internal/clustering/state/main_test.go
new file mode 100644
index 0000000..ea5243a
--- /dev/null
+++ b/analysis/internal/clustering/state/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package state
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/clustering/state/span.go b/analysis/internal/clustering/state/span.go
new file mode 100644
index 0000000..e73199c
--- /dev/null
+++ b/analysis/internal/clustering/state/span.go
@@ -0,0 +1,433 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package state
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"math"
+	"math/big"
+	"strings"
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+)
+
+// Entry represents the clustering state of a chunk, consisting of:
+// - Metadata about what test results were clustered.
+// - Metadata about how the test results were clustered (the algorithms
+//   and failure association rules used).
+// - The clusters each test result are in.
+type Entry struct {
+	// Project is the LUCI Project the chunk belongs to.
+	Project string
+	// ChunkID is the identity of the chunk of test results. 32 lowercase hexadecimal
+	// characters assigned by the ingestion process.
+	ChunkID string
+	// PartitionTime is the start of the retention period of the test results in the chunk.
+	PartitionTime time.Time
+	// ObjectID is the identity of the object in GCS containing the chunk's test results.
+	// 32 lowercase hexadecimal characters.
+	ObjectID string
+	// Clustering describes the latest clustering of test results in
+	// the chunk.
+	Clustering clustering.ClusterResults
+	// LastUpdated is the Spanner commit time the row was last updated. Output only.
+	LastUpdated time.Time
+}
+
+// NotFound is the error returned by Read if the row could not be found.
+var NotFoundErr = errors.New("clustering state row not found")
+
+// EndOfTable is the highest possible chunk ID that can be stored.
+var EndOfTable = strings.Repeat("ff", 16)
+
+// Create inserts clustering state for a chunk. Must be
+// called in the context of a Spanner transaction.
+func Create(ctx context.Context, e *Entry) error {
+	if err := validateEntry(e); err != nil {
+		return err
+	}
+	clusters, err := encodeClusters(e.Clustering.Algorithms, e.Clustering.Clusters)
+	if err != nil {
+		return err
+	}
+	ms := spanutil.InsertMap("ClusteringState", map[string]interface{}{
+		"Project":           e.Project,
+		"ChunkID":           e.ChunkID,
+		"PartitionTime":     e.PartitionTime,
+		"ObjectID":          e.ObjectID,
+		"AlgorithmsVersion": e.Clustering.AlgorithmsVersion,
+		"ConfigVersion":     e.Clustering.ConfigVersion,
+		"RulesVersion":      e.Clustering.RulesVersion,
+		"Clusters":          clusters,
+		"LastUpdated":       spanner.CommitTimestamp,
+	})
+	span.BufferWrite(ctx, ms)
+	return nil
+}
+
+// ChunkKey represents the identify of a chunk.
+type ChunkKey struct {
+	Project string
+	ChunkID string
+}
+
+// String returns a string representation of the key, for use in
+// dictionaries.
+func (k ChunkKey) String() string {
+	return fmt.Sprintf("%q/%q", k.Project, k.ChunkID)
+}
+
+// ReadLastUpdated reads the last updated time of the specified chunks.
+// If the chunk does not exist, the zero time value time.Time{} is returned.
+// Unless an error is returned, the returned slice will be of the same length
+// as chunkIDs. The i-th LastUpdated time returned will correspond
+// to the i-th chunk ID requested.
+func ReadLastUpdated(ctx context.Context, keys []ChunkKey) ([]time.Time, error) {
+	var ks []spanner.Key
+	for _, key := range keys {
+		ks = append(ks, spanner.Key{key.Project, key.ChunkID})
+	}
+
+	results := make(map[string]time.Time)
+	columns := []string{"Project", "ChunkID", "LastUpdated"}
+	it := span.Read(ctx, "ClusteringState", spanner.KeySetFromKeys(ks...), columns)
+	err := it.Do(func(r *spanner.Row) error {
+		var project string
+		var chunkID string
+		var lastUpdated time.Time
+		if err := r.Columns(&project, &chunkID, &lastUpdated); err != nil {
+			return errors.Annotate(err, "read clustering state row").Err()
+		}
+		key := ChunkKey{project, chunkID}
+		results[key.String()] = lastUpdated
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	result := make([]time.Time, len(keys))
+	for i, key := range keys {
+		// If an entry does not exist in results, this will set the
+		// default value for *time.Time, which is nil.
+		result[i] = results[key.String()]
+	}
+	return result, nil
+}
+
+// UpdateClustering updates the clustering results on a chunk.
+//
+// To avoid clobbering other concurrent updates, the caller should read
+// the LastUpdated time of the chunk in the same transaction as it is
+// updated (i.e. using ReadLastUpdated) and verify it matches the previous
+// entry passed.
+//
+// The update uses the previous entry to avoid writing cluster data
+// if it has not changed, which optimises the performance of minor
+// reclusterings.
+func UpdateClustering(ctx context.Context, previous *Entry, update *clustering.ClusterResults) error {
+	if err := validateClusterResults(update); err != nil {
+		return err
+	}
+
+	upd := make(map[string]interface{})
+	upd["Project"] = previous.Project
+	upd["ChunkID"] = previous.ChunkID
+	upd["LastUpdated"] = spanner.CommitTimestamp
+	upd["AlgorithmsVersion"] = update.AlgorithmsVersion
+	upd["ConfigVersion"] = update.ConfigVersion
+	upd["RulesVersion"] = update.RulesVersion
+
+	if !clustering.AlgorithmsAndClustersEqual(&previous.Clustering, update) {
+		// Clusters is a field that may be many kilobytes in size.
+		// For efficiency, only write it to Spanner if it is changed.
+		clusters, err := encodeClusters(update.Algorithms, update.Clusters)
+		if err != nil {
+			return err
+		}
+		upd["Clusters"] = clusters
+	}
+
+	span.BufferWrite(ctx, spanutil.UpdateMap("ClusteringState", upd))
+	return nil
+}
+
+// Read reads clustering state for a chunk. Must be
+// called in the context of a Spanner transaction. If no clustering
+// state exists, the method returns the error NotFound.
+func Read(ctx context.Context, project, chunkID string) (*Entry, error) {
+	whereClause := "ChunkID = @chunkID"
+	params := make(map[string]interface{})
+	params["chunkID"] = chunkID
+
+	limit := 1
+	results, err := readWhere(ctx, project, whereClause, params, limit)
+	if err != nil {
+		return nil, err
+	}
+	if len(results) == 0 {
+		// Row does not exist.
+		return nil, NotFoundErr
+	}
+	return results[0], nil
+}
+
+// ReadNextOptions specifies options for ReadNextN.
+type ReadNextOptions struct {
+	// The exclusive lower bound of the range of ChunkIDs to read.
+	// To read from the start of the table, leave this blank ("").
+	StartChunkID string
+	// The inclusive upper bound of the range of ChunkIDs to read.
+	// To specify the end of the table, use the constant EndOfTable.
+	EndChunkID string
+	// The minimum AlgorithmsVersion that re-clustering wants to achieve.
+	// If a row has an AlgorithmsVersion less than this value, it will
+	// be eligble to be read.
+	AlgorithmsVersion int64
+	// The minimum ConfigVersion that re-clustering wants to achieve.
+	// If a row has an RulesVersion less than this value, it will
+	// be eligble to be read.
+	ConfigVersion time.Time
+	// The minimum RulesVersion that re-clustering wants to achieve.
+	// If a row has an RulesVersion less than this value, it will
+	// be eligble to be read.
+	RulesVersion time.Time
+}
+
+// ReadNextN reads the n consecutively next clustering state entries
+// matching ReadNextOptions.
+func ReadNextN(ctx context.Context, project string, opts ReadNextOptions, n int) ([]*Entry, error) {
+	params := make(map[string]interface{})
+	whereClause := `
+		ChunkId > @startChunkID AND ChunkId <= @endChunkID
+		AND (AlgorithmsVersion < @algorithmsVersion
+			OR ConfigVersion < @configVersion
+			OR RulesVersion < @rulesVersion)
+	`
+	params["startChunkID"] = opts.StartChunkID
+	params["endChunkID"] = opts.EndChunkID
+	params["algorithmsVersion"] = opts.AlgorithmsVersion
+	params["configVersion"] = opts.ConfigVersion
+	params["rulesVersion"] = opts.RulesVersion
+
+	return readWhere(ctx, project, whereClause, params, n)
+}
+
+func readWhere(ctx context.Context, project, whereClause string, params map[string]interface{}, limit int) ([]*Entry, error) {
+	stmt := spanner.NewStatement(`
+		SELECT
+		  ChunkId, PartitionTime, ObjectId,
+		  AlgorithmsVersion,
+		  ConfigVersion, RulesVersion,
+		  LastUpdated, Clusters
+		FROM ClusteringState
+		WHERE Project = @project AND (` + whereClause + `)
+		ORDER BY ChunkId
+		LIMIT @limit
+	`)
+	for k, v := range params {
+		stmt.Params[k] = v
+	}
+	stmt.Params["project"] = project
+	stmt.Params["limit"] = limit
+
+	it := span.Query(ctx, stmt)
+	var b spanutil.Buffer
+	results := []*Entry{}
+	err := it.Do(func(r *spanner.Row) error {
+		clusters := &cpb.ChunkClusters{}
+		result := &Entry{Project: project}
+
+		err := b.FromSpanner(r,
+			&result.ChunkID, &result.PartitionTime, &result.ObjectID,
+			&result.Clustering.AlgorithmsVersion,
+			&result.Clustering.ConfigVersion, &result.Clustering.RulesVersion,
+			&result.LastUpdated, clusters)
+		if err != nil {
+			return errors.Annotate(err, "read clustering state row").Err()
+		}
+
+		result.Clustering.Algorithms, result.Clustering.Clusters, err = decodeClusters(clusters)
+		if err != nil {
+			return errors.Annotate(err, "decode clusters").Err()
+		}
+		results = append(results, result)
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return results, nil
+}
+
+// EstimateChunks estimates the total number of chunks in the ClusteringState
+// table for the given project.
+func EstimateChunks(ctx context.Context, project string) (int, error) {
+	stmt := spanner.NewStatement(`
+	  SELECT ChunkId
+	  FROM ClusteringState
+	  WHERE Project = @project
+	  ORDER BY ChunkId ASC
+	  LIMIT 1 OFFSET 100
+	`)
+	stmt.Params["project"] = project
+
+	it := span.Query(ctx, stmt)
+	var chunkID string
+	err := it.Do(func(r *spanner.Row) error {
+		if err := r.Columns(&chunkID); err != nil {
+			return errors.Annotate(err, "read ChunkID row").Err()
+		}
+		return nil
+	})
+	if err != nil {
+		return 0, err
+	}
+	if chunkID == "" {
+		// There was no 100th chunk ID. There must be less
+		// than 100 chunks in the project.
+		return 99, nil
+	}
+	return estimateChunksFromID(chunkID)
+}
+
+// estimateChunksFromID estimates the number of chunks in a project
+// given the ID of the 100th chunk (in ascending keyspace order) in
+// that project. The maximum estimate that will be returned is one
+// billion. If there is no 100th chunk ID in the project, then
+// there are clearly 99 chunks or less in the project.
+func estimateChunksFromID(chunkID100 string) (int, error) {
+	const MaxEstimate = 1000 * 1000 * 1000
+	// This function uses the property that ChunkIDs are approximately
+	// uniformly distributed. We use the following estimator of the
+	// number of rows:
+	//   100 / (fraction of keyspace used up to 100th row)
+	// where fraction of keyspace used up to 100th row is:
+	//   (ChunkID_100th + 1) / 2^128
+	//
+	// Where ChunkID_100th is the ChunkID of the 100th row (in keyspace
+	// order), as a 128-bit integer (rather than hexadecimal string).
+	//
+	// Rearranging this estimator, we get:
+	//   100 * 2^128 / (ChunkID_100th + 1)
+
+	// numerator = 100 * 2 ^ 128
+	var numerator big.Int
+	numerator.Lsh(big.NewInt(100), 128)
+
+	idBytes, err := hex.DecodeString(chunkID100)
+	if err != nil {
+		return 0, err
+	}
+
+	// denominator = ChunkID_100th + 1. We add one because
+	// the keyspace consumed includes the ID itself.
+	var denominator big.Int
+	denominator.SetBytes(idBytes)
+	denominator.Add(&denominator, big.NewInt(1))
+
+	// estimate = numerator / denominator.
+	var estimate big.Int
+	estimate.Div(&numerator, &denominator)
+
+	result := uint64(math.MaxUint64)
+	if estimate.IsUint64() {
+		result = estimate.Uint64()
+	}
+	if result > MaxEstimate {
+		result = MaxEstimate
+	}
+	return int(result), nil
+}
+
+func validateEntry(e *Entry) error {
+	switch {
+	case !config.ProjectRe.MatchString(e.Project):
+		return fmt.Errorf("project %q is not valid", e.Project)
+	case !clustering.ChunkRe.MatchString(e.ChunkID):
+		return fmt.Errorf("chunk ID %q is not valid", e.ChunkID)
+	case e.PartitionTime.IsZero():
+		return errors.New("partition time must be specified")
+	case e.ObjectID == "":
+		return errors.New("object ID must be specified")
+	default:
+		if err := validateClusterResults(&e.Clustering); err != nil {
+			return err
+		}
+		return nil
+	}
+}
+
+func validateClusterResults(c *clustering.ClusterResults) error {
+	switch {
+	case c.AlgorithmsVersion <= 0:
+		return errors.New("algorithms version must be specified")
+	case c.ConfigVersion.Before(config.StartingEpoch):
+		return errors.New("config version must be valid")
+	case c.RulesVersion.Before(rules.StartingEpoch):
+		return errors.New("rules version must be valid")
+	default:
+		if err := validateAlgorithms(c.Algorithms); err != nil {
+			return errors.Annotate(err, "algorithms").Err()
+		}
+		if err := validateClusters(c.Clusters, c.Algorithms); err != nil {
+			return errors.Annotate(err, "clusters").Err()
+		}
+		return nil
+	}
+}
+
+func validateAlgorithms(algorithms map[string]struct{}) error {
+	for a := range algorithms {
+		if !clustering.AlgorithmRe.MatchString(a) {
+			return fmt.Errorf("algorithm %q is not valid", a)
+		}
+	}
+	return nil
+}
+
+func validateClusters(clusters [][]clustering.ClusterID, algorithms map[string]struct{}) error {
+	if len(clusters) == 0 {
+		// Each chunk must have at least one test result, even
+		// if that test result is in no clusters.
+		return errors.New("there must be clustered test results in the chunk")
+	}
+	// Outer slice has on entry per test result.
+	for i, tr := range clusters {
+		// Inner slice has the list of clusters per test result.
+		for j, c := range tr {
+			if _, ok := algorithms[c.Algorithm]; !ok {
+				return fmt.Errorf("test result %v: cluster %v: algorithm not in algorithms list: %q", i, j, c.Algorithm)
+			}
+			if err := c.ValidateIDPart(); err != nil {
+				return errors.Annotate(err, "test result %v: cluster %v: cluster ID is not valid", i, j).Err()
+			}
+		}
+		if !clustering.ClustersAreSortedNoDuplicates(tr) {
+			return fmt.Errorf("test result %v: clusters are not sorted, or there are duplicates: %v", i, tr)
+		}
+	}
+	return nil
+}
diff --git a/analysis/internal/clustering/state/span_test.go b/analysis/internal/clustering/state/span_test.go
new file mode 100644
index 0000000..ac09ce2
--- /dev/null
+++ b/analysis/internal/clustering/state/span_test.go
@@ -0,0 +1,342 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package state
+
+import (
+	"context"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/testutil"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestSpanner(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		Convey(`Create`, func() {
+			testCreate := func(e *Entry) (time.Time, error) {
+				commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					return Create(ctx, e)
+				})
+				return commitTime, err
+			}
+			e := NewEntry(100).Build()
+			Convey(`Valid`, func() {
+				commitTime, err := testCreate(e)
+				So(err, ShouldBeNil)
+				e.LastUpdated = commitTime.In(time.UTC)
+
+				txn := span.Single(ctx)
+				actual, err := Read(txn, e.Project, e.ChunkID)
+				So(err, ShouldBeNil)
+				So(actual, ShouldResemble, e)
+			})
+			Convey(`Invalid`, func() {
+				Convey(`Project missing`, func() {
+					e.Project = ""
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, `project "" is not valid`)
+				})
+				Convey(`Chunk ID missing`, func() {
+					e.ChunkID = ""
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, `chunk ID "" is not valid`)
+				})
+				Convey(`Partition Time missing`, func() {
+					var t time.Time
+					e.PartitionTime = t
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, "partition time must be specified")
+				})
+				Convey(`Object ID missing`, func() {
+					e.ObjectID = ""
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, "object ID must be specified")
+				})
+				Convey(`Config Version missing`, func() {
+					var t time.Time
+					e.Clustering.ConfigVersion = t
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, "config version must be valid")
+				})
+				Convey(`Rules Version missing`, func() {
+					var t time.Time
+					e.Clustering.RulesVersion = t
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, "rules version must be valid")
+				})
+				Convey(`Algorithms Version missing`, func() {
+					e.Clustering.AlgorithmsVersion = 0
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, "algorithms version must be specified")
+				})
+				Convey(`Clusters missing`, func() {
+					e.Clustering.Clusters = nil
+					_, err := testCreate(e)
+					So(err, ShouldErrLike, "there must be clustered test results in the chunk")
+				})
+				Convey(`Algorithms invalid`, func() {
+					Convey(`Empty algorithm`, func() {
+						e.Clustering.Algorithms[""] = struct{}{}
+						_, err := testCreate(e)
+						So(err, ShouldErrLike, `algorithm "" is not valid`)
+					})
+					Convey("Algorithm invalid", func() {
+						e.Clustering.Algorithms["!!!"] = struct{}{}
+						_, err := testCreate(e)
+						So(err, ShouldErrLike, `algorithm "!!!" is not valid`)
+					})
+				})
+				Convey(`Clusters invalid`, func() {
+					Convey("Algorithm not in algorithms set", func() {
+						e.Clustering.Algorithms = map[string]struct{}{}
+						_, err := testCreate(e)
+						So(err, ShouldErrLike, `clusters: test result 0: cluster 0: algorithm not in algorithms list`)
+					})
+					Convey("ID missing", func() {
+						e.Clustering.Clusters[1][1].ID = ""
+						_, err := testCreate(e)
+						So(err, ShouldErrLike, `clusters: test result 1: cluster 1: cluster ID is not valid: ID is empty`)
+					})
+				})
+			})
+		})
+		Convey(`UpdateClustering`, func() {
+			Convey(`Valid`, func() {
+				entry := NewEntry(0).Build()
+				entries := []*Entry{
+					entry,
+				}
+				commitTime, err := CreateEntriesForTesting(ctx, entries)
+				So(err, ShouldBeNil)
+				entry.LastUpdated = commitTime.In(time.UTC)
+
+				expected := NewEntry(0).Build()
+
+				test := func(update clustering.ClusterResults, expected *Entry) {
+					// Apply the update.
+					commitTime, err = span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+						err := UpdateClustering(ctx, entry, &update)
+						return err
+					})
+					So(err, ShouldEqual, nil)
+					expected.LastUpdated = commitTime.In(time.UTC)
+
+					// Assert the update was applied.
+					actual, err := Read(span.Single(ctx), expected.Project, expected.ChunkID)
+					So(err, ShouldBeNil)
+					So(actual, ShouldResemble, expected)
+				}
+				Convey(`Full update`, func() {
+					// Prepare an update.
+					newClustering := NewEntry(1).Build().Clustering
+					expected.Clustering = newClustering
+
+					So(clustering.AlgorithmsAndClustersEqual(&entry.Clustering, &newClustering), ShouldBeFalse)
+					test(newClustering, expected)
+				})
+				Convey(`Minor update`, func() {
+					// Update only algorithms + rules + config version, without changing clustering content.
+					newClustering := NewEntry(0).
+						WithAlgorithmsVersion(10).
+						WithConfigVersion(time.Date(2024, time.July, 5, 4, 3, 2, 1, time.UTC)).
+						WithRulesVersion(time.Date(2024, time.June, 5, 4, 3, 2, 1000, time.UTC)).
+						Build().Clustering
+
+					expected.Clustering = newClustering
+					So(clustering.AlgorithmsAndClustersEqual(&entries[0].Clustering, &newClustering), ShouldBeTrue)
+					test(newClustering, expected)
+				})
+				Convey(`No-op update`, func() {
+					test(entry.Clustering, expected)
+				})
+			})
+			Convey(`Invalid`, func() {
+				originalEntry := NewEntry(0).Build()
+				newClustering := &NewEntry(0).Build().Clustering
+
+				// Try an invalid algorithm. We do not repeat all the same
+				// validation test cases as create, as the underlying
+				// implementation is the same.
+				newClustering.Algorithms["!!!"] = struct{}{}
+
+				_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					err := UpdateClustering(ctx, originalEntry, newClustering)
+					return err
+				})
+				So(err, ShouldErrLike, `algorithm "!!!" is not valid`)
+			})
+		})
+		Convey(`ReadLastUpdated`, func() {
+			// Create two entries at different times to give them different LastUpdated times.
+			entryOne := NewEntry(0).Build()
+			lastUpdatedOne, err := CreateEntriesForTesting(ctx, []*Entry{entryOne})
+			So(err, ShouldBeNil)
+
+			entryTwo := NewEntry(1).Build()
+			lastUpdatedTwo, err := CreateEntriesForTesting(ctx, []*Entry{entryTwo})
+			So(err, ShouldBeNil)
+
+			chunkKeys := []ChunkKey{
+				{Project: testProject, ChunkID: entryOne.ChunkID},
+				{Project: "otherproject", ChunkID: entryOne.ChunkID},
+				{Project: testProject, ChunkID: "1234567890abcdef1234567890abcdef"},
+				{Project: testProject, ChunkID: entryTwo.ChunkID},
+			}
+
+			actual, err := ReadLastUpdated(span.Single(ctx), chunkKeys)
+			So(err, ShouldBeNil)
+			So(len(actual), ShouldEqual, len(chunkKeys))
+			So(actual[0], ShouldEqual, lastUpdatedOne)
+			So(actual[1], ShouldEqual, time.Time{})
+			So(actual[2], ShouldEqual, time.Time{})
+			So(actual[3], ShouldEqual, lastUpdatedTwo)
+		})
+		Convey(`ReadNextN`, func() {
+			targetRulesVersion := time.Date(2024, 1, 1, 1, 1, 1, 0, time.UTC)
+			targetConfigVersion := time.Date(2024, 2, 1, 1, 1, 1, 0, time.UTC)
+			targetAlgorithmsVersion := 10
+			entries := []*Entry{
+				// Should not be read.
+				NewEntry(0).WithChunkIDPrefix("11").
+					WithAlgorithmsVersion(10).
+					WithConfigVersion(targetConfigVersion).
+					WithRulesVersion(targetRulesVersion).Build(),
+
+				// Should be read (rulesVersion < targetRulesVersion).
+				NewEntry(1).WithChunkIDPrefix("11").
+					WithAlgorithmsVersion(10).
+					WithConfigVersion(targetConfigVersion).
+					WithRulesVersion(targetRulesVersion.Add(-1 * time.Hour)).Build(),
+				NewEntry(2).WithChunkIDPrefix("11").
+					WithRulesVersion(targetRulesVersion.Add(-1 * time.Hour)).Build(),
+
+				// Should be read (configVersion < targetConfigVersion).
+				NewEntry(3).WithChunkIDPrefix("11").
+					WithAlgorithmsVersion(10).
+					WithConfigVersion(targetConfigVersion.Add(-1 * time.Hour)).
+					WithRulesVersion(targetRulesVersion).Build(),
+				NewEntry(4).WithChunkIDPrefix("11").
+					WithConfigVersion(targetConfigVersion.Add(-1 * time.Hour)).Build(),
+
+				// Should be read (algorithmsVersion < targetAlgorithmsVersion).
+				NewEntry(5).WithChunkIDPrefix("11").
+					WithAlgorithmsVersion(9).
+					WithConfigVersion(targetConfigVersion).
+					WithRulesVersion(targetRulesVersion).Build(),
+				NewEntry(6).WithChunkIDPrefix("11").
+					WithAlgorithmsVersion(2).Build(),
+
+				// Should not be read (other project).
+				NewEntry(7).WithChunkIDPrefix("11").
+					WithAlgorithmsVersion(2).
+					WithProject("other").Build(),
+
+				// Check handling of EndChunkID as an inclusive upper-bound.
+				NewEntry(8).WithChunkIDPrefix("11" + strings.Repeat("ff", 15)).WithAlgorithmsVersion(2).Build(), // Should be read.
+				NewEntry(9).WithChunkIDPrefix("12" + strings.Repeat("00", 15)).WithAlgorithmsVersion(2).Build(), // Should not be read.
+			}
+
+			commitTime, err := CreateEntriesForTesting(ctx, entries)
+			for _, e := range entries {
+				e.LastUpdated = commitTime.In(time.UTC)
+			}
+			So(err, ShouldBeNil)
+
+			expectedEntries := []*Entry{
+				entries[1],
+				entries[2],
+				entries[3],
+				entries[4],
+				entries[5],
+				entries[6],
+				entries[8],
+			}
+			sort.Slice(expectedEntries, func(i, j int) bool {
+				return expectedEntries[i].ChunkID < expectedEntries[j].ChunkID
+			})
+
+			readOpts := ReadNextOptions{
+				StartChunkID:      "11" + strings.Repeat("00", 15),
+				EndChunkID:        "11" + strings.Repeat("ff", 15),
+				AlgorithmsVersion: int64(targetAlgorithmsVersion),
+				ConfigVersion:     targetConfigVersion,
+				RulesVersion:      targetRulesVersion,
+			}
+			// Reads first page.
+			rows, err := ReadNextN(span.Single(ctx), testProject, readOpts, 4)
+			So(err, ShouldBeNil)
+			So(rows, ShouldResemble, expectedEntries[0:4])
+
+			// Read second page.
+			readOpts.StartChunkID = rows[3].ChunkID
+			rows, err = ReadNextN(span.Single(ctx), testProject, readOpts, 4)
+			So(err, ShouldBeNil)
+			So(rows, ShouldResemble, expectedEntries[4:])
+
+			// Read empty last page.
+			readOpts.StartChunkID = rows[2].ChunkID
+			rows, err = ReadNextN(span.Single(ctx), testProject, readOpts, 4)
+			So(err, ShouldBeNil)
+			So(rows, ShouldBeEmpty)
+		})
+		Convey(`EstimateChunks`, func() {
+			Convey(`Less than 100 chunks`, func() {
+				est, err := EstimateChunks(span.Single(ctx), testProject)
+				So(err, ShouldBeNil)
+				So(est, ShouldBeLessThan, 100)
+			})
+			Convey(`At least 100 chunks`, func() {
+				var entries []*Entry
+				for i := 0; i < 200; i++ {
+					entries = append(entries, NewEntry(i).Build())
+				}
+				_, err := CreateEntriesForTesting(ctx, entries)
+				So(err, ShouldBeNil)
+
+				count, err := EstimateChunks(span.Single(ctx), testProject)
+				So(err, ShouldBeNil)
+				So(count, ShouldBeGreaterThan, 190)
+				So(count, ShouldBeLessThan, 210)
+			})
+		})
+	})
+	Convey(`estimateChunksFromID`, t, func() {
+		// Extremely full table. This is the minimum that the 100th ID
+		// could be (considering 0x63 = 99).
+		count, err := estimateChunksFromID("00000000000000000000000000000063")
+		So(err, ShouldBeNil)
+		// The maximum estimate.
+		So(count, ShouldEqual, 1000*1000*1000)
+
+		// The 100th ID is right in the middle of the keyspace.
+		count, err = estimateChunksFromID("7fffffffffffffffffffffffffffffff")
+		So(err, ShouldBeNil)
+		So(count, ShouldEqual, 200)
+
+		// The 100th ID is right at the end of the keyspace.
+		count, err = estimateChunksFromID("ffffffffffffffffffffffffffffffff")
+		So(err, ShouldBeNil)
+		So(count, ShouldEqual, 100)
+	})
+}
diff --git a/analysis/internal/clustering/state/testutils.go b/analysis/internal/clustering/state/testutils.go
new file mode 100644
index 0000000..395add1
--- /dev/null
+++ b/analysis/internal/clustering/state/testutils.go
@@ -0,0 +1,136 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package state
+
+import (
+	"context"
+	"crypto/sha256"
+	"encoding/binary"
+	"encoding/hex"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+)
+
+const testProject = "myproject"
+
+// EntryBuilder provides methods to build a new Entry.
+type EntryBuilder struct {
+	entry *Entry
+}
+
+// NewEntry creates a new entry builder with the given uniqifier.
+// The uniqifier affects the ChunkID, AlgorithmVersion, RulesVersion
+// and Algorithms.
+func NewEntry(uniqifier int) *EntryBuilder {
+	// Generate a 128-bit chunkID from the uniqifier.
+	// Using a hash function ensures they will be approximately uniformly
+	// distributed through the keyspace.
+	var b [8]byte
+	binary.BigEndian.PutUint64(b[:], uint64(uniqifier))
+	sum := sha256.Sum256(b[:])
+
+	entry := &Entry{
+		Project:       testProject,
+		ChunkID:       hex.EncodeToString(sum[0:16]),
+		PartitionTime: time.Date(2030, 1, 1, 1, 1, 1, uniqifier, time.UTC),
+		ObjectID:      "abcdef1234567890abcdef1234567890",
+		Clustering: clustering.ClusterResults{
+			AlgorithmsVersion: int64(uniqifier + 1),
+			ConfigVersion:     time.Date(2025, 2, 1, 1, 1, 1, uniqifier, time.UTC),
+			RulesVersion:      time.Date(2025, 1, 1, 1, 1, 1, uniqifier, time.UTC),
+			Algorithms: map[string]struct{}{
+				fmt.Sprintf("alg-%v-v1", uniqifier): {},
+				"alg-extra-v1":                      {},
+			},
+			Clusters: [][]clustering.ClusterID{
+				{
+					{
+						Algorithm: fmt.Sprintf("alg-%v-v1", uniqifier),
+						ID:        "00112233445566778899aabbccddeeff",
+					},
+				},
+				{
+					{
+						Algorithm: fmt.Sprintf("alg-%v-v1", uniqifier),
+						ID:        "00112233445566778899aabbccddeeff",
+					},
+					{
+						Algorithm: fmt.Sprintf("alg-%v-v1", uniqifier),
+						ID:        "22",
+					},
+				},
+			},
+		},
+	}
+	return &EntryBuilder{entry}
+}
+
+// WithChunkIDPrefix specifies the start of the ChunkID to use. The remaining
+// ChunkID will be derived from the uniqifier.
+func (b *EntryBuilder) WithChunkIDPrefix(prefix string) *EntryBuilder {
+	b.entry.ChunkID = prefix + b.entry.ChunkID[len(prefix):]
+	return b
+}
+
+// WithProject specifies the LUCI project for the entry.
+func (b *EntryBuilder) WithProject(project string) *EntryBuilder {
+	b.entry.Project = project
+	return b
+}
+
+// WithAlgorithmsVersion specifies the algorithms version for the entry.
+func (b *EntryBuilder) WithAlgorithmsVersion(version int64) *EntryBuilder {
+	b.entry.Clustering.AlgorithmsVersion = version
+	return b
+}
+
+// WithConfigVersion specifies the config version for the entry.
+func (b *EntryBuilder) WithConfigVersion(version time.Time) *EntryBuilder {
+	b.entry.Clustering.ConfigVersion = version
+	return b
+}
+
+// WithRulesVersion specifies the rules version for the entry.
+func (b *EntryBuilder) WithRulesVersion(version time.Time) *EntryBuilder {
+	b.entry.Clustering.RulesVersion = version
+	return b
+}
+
+// Build returns the built entry.
+func (b *EntryBuilder) Build() *Entry {
+	return b.entry
+}
+
+// CreateEntriesForTesting creates the given entries, for testing.
+func CreateEntriesForTesting(ctx context.Context, entries []*Entry) (commitTimestamp time.Time, err error) {
+	return span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		for _, e := range entries {
+			if err := Create(ctx, e); err != nil {
+				return err
+			}
+		}
+		return nil
+	})
+}
+
+// ReadAllForTesting reads all state entries in the given project
+// (up to 1 million records) for testing.
+func ReadAllForTesting(ctx context.Context, project string) ([]*Entry, error) {
+	return readWhere(span.Single(ctx), project, "TRUE", nil, 1000*1000)
+}
diff --git a/analysis/internal/clustering/update.go b/analysis/internal/clustering/update.go
new file mode 100644
index 0000000..87207ce
--- /dev/null
+++ b/analysis/internal/clustering/update.go
@@ -0,0 +1,43 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package clustering
+
+import (
+	cpb "go.chromium.org/luci/analysis/internal/clustering/proto"
+)
+
+// Update describes changes made to the clustering of a chunk.
+type Update struct {
+	// Project is the LUCI Project containing the chunk which is being
+	// (re-)clustered.
+	Project string
+	// ChunkID is the identity of the chunk which is being (re-)clustered.
+	ChunkID string
+	// Updates describes how each failure in the cluster was (re)clustered.
+	// It contains one entry for each failure in the cluster that has
+	// had its clusters changed.
+	Updates []*FailureUpdate
+}
+
+// FailureUpdate describes the changes made to the clustering
+// of a specific test failure.
+type FailureUpdate struct {
+	// TestResult is the failure that was re-clustered.
+	TestResult *cpb.Failure
+	// PreviousClusters are the clusters the failure was previously in.
+	PreviousClusters []ClusterID
+	// PreviousClusters are the clusters the failure is now in.
+	NewClusters []ClusterID
+}
diff --git a/analysis/internal/config/compiledcfg/config.go b/analysis/internal/config/compiledcfg/config.go
new file mode 100644
index 0000000..1d011e2
--- /dev/null
+++ b/analysis/internal/config/compiledcfg/config.go
@@ -0,0 +1,132 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package compiledcfg contains compiled versions of the Weetbix config.
+// (E.g. Regular expressions are compiled for efficiency.)
+package compiledcfg
+
+import (
+	"context"
+	"time"
+
+	"go.chromium.org/luci/common/data/caching/lru"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/caching"
+
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// TODO(crbug.com/1243174). Instrument the size of this cache so that we
+// can monitor it.
+var configCache = caching.RegisterLRUCache(0)
+
+// NotExistsErr is returned if no matching configuration could be found
+// for the specified project.
+var NotExistsErr = errors.New("no config exists for the specified project")
+
+// ProjectConfig is a compiled version of Weetbix project configuration.
+type ProjectConfig struct {
+	// Config is the raw, uncompiled, configuration.
+	Config *configpb.ProjectConfig
+
+	// TestNameRules are the set of rules to use to cluster test results
+	// by test name.
+	TestNameRules []rules.Evaluator
+
+	// LastUpdated is the time the configuration was last updated.
+	LastUpdated time.Time
+}
+
+// NewConfig compiles the given clustering configuration into a Config
+// object.
+func NewConfig(config *configpb.ProjectConfig) (*ProjectConfig, error) {
+	rs := config.Clustering.GetTestNameRules()
+	compiledRules := make([]rules.Evaluator, len(rs))
+	for i, rule := range rs {
+		eval, err := rules.Compile(rule)
+		if err != nil {
+			return nil, errors.Annotate(err, "compiling test name clustering rule").Err()
+		}
+		compiledRules[i] = eval
+	}
+	return &ProjectConfig{
+		Config:        config,
+		TestNameRules: compiledRules,
+		LastUpdated:   config.LastUpdated.AsTime(),
+	}, nil
+}
+
+// Project returns the clustering configuration for the given project,
+// with a LastUpdated time of at least minimumVersion. If no particular
+// minimum version is desired, pass time.Time{} to minimumVersion.
+func Project(ctx context.Context, project string, minimumVersion time.Time) (*ProjectConfig, error) {
+	cache := configCache.LRU(ctx)
+	if cache == nil {
+		// A fallback useful in unit tests that may not have the process cache
+		// available. Production environments usually have the cache installed
+		// by the framework code that initializes the root context.
+		projectCfg, err := config.ProjectWithMinimumVersion(ctx, project, minimumVersion)
+		if err != nil {
+			if err == config.NotExistsErr {
+				return nil, NotExistsErr
+			}
+			return nil, err
+		}
+		config, err := NewConfig(projectCfg)
+		if err != nil {
+			return nil, err
+		}
+		return config, nil
+	} else {
+		var err error
+		val, _ := cache.Mutate(ctx, project, func(it *lru.Item) *lru.Item {
+			var projectCfg *configpb.ProjectConfig
+			// Fetch the latest configuration for the given project, with
+			// the specified minimum version.
+			projectCfg, err = config.ProjectWithMinimumVersion(ctx, project, minimumVersion)
+			if err != nil {
+				// Delete cached value.
+				return nil
+			}
+
+			if it != nil {
+				cfg := it.Value.(*ProjectConfig)
+				if cfg.LastUpdated.Equal(projectCfg.LastUpdated.AsTime()) {
+					// Cached value is already up to date.
+					return it
+				}
+			}
+			var config *ProjectConfig
+			config, err = NewConfig(projectCfg)
+			if err != nil {
+				// Delete cached value.
+				return nil
+			}
+			return &lru.Item{
+				Value: config,
+				Exp:   0, // No expiry.
+			}
+		})
+		if err != nil {
+			if err == config.NotExistsErr {
+				return nil, NotExistsErr
+			}
+			return nil, errors.Annotate(err, "obtain compiled configuration").Err()
+		}
+		cfg := val.(*ProjectConfig)
+		return cfg, nil
+	}
+}
diff --git a/analysis/internal/config/compiledcfg/config_test.go b/analysis/internal/config/compiledcfg/config_test.go
new file mode 100644
index 0000000..e449202
--- /dev/null
+++ b/analysis/internal/config/compiledcfg/config_test.go
@@ -0,0 +1,159 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package compiledcfg
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/caching"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestCompiledConfig(t *testing.T) {
+	Convey(`With In-Process Cache`, t, func() {
+		ctx := context.Background()
+		ctx = memory.Use(ctx)
+		ctx = caching.WithEmptyProcessCache(ctx)
+		ctx, tc := testclock.UseTime(ctx, testclock.TestTimeUTC)
+
+		create := func(uniqifier int) {
+			cfg := generateProjectConfig(uniqifier)
+			projectsCfg := map[string]*configpb.ProjectConfig{
+				"myproject": cfg,
+			}
+			err := config.SetTestProjectConfig(ctx, projectsCfg)
+			So(err, ShouldBeNil)
+		}
+		clear := func() {
+			projectsCfg := map[string]*configpb.ProjectConfig{}
+			err := config.SetTestProjectConfig(ctx, projectsCfg)
+			So(err, ShouldBeNil)
+		}
+		verify := func(minimumVersion time.Time, uniqifier int) {
+			cfg, err := Project(ctx, "myproject", minimumVersion)
+			So(err, ShouldBeNil)
+
+			expectedCfg := generateProjectConfig(uniqifier)
+			So(cfg.Config, ShouldResembleProto, expectedCfg)
+			So(cfg.LastUpdated, ShouldEqual, expectedCfg.LastUpdated.AsTime())
+			So(len(cfg.TestNameRules), ShouldEqual, 1)
+
+			testName := fmt.Sprintf(`ninja://test_name/%v`, uniqifier)
+			rule := cfg.TestNameRules[0]
+			like, ok := rule(testName)
+			So(ok, ShouldBeTrue)
+			So(like, ShouldEqual, testName+"%")
+		}
+		verifyNotExists := func(minimumVersion time.Time) {
+			cfg, err := Project(ctx, "myproject", minimumVersion)
+			So(err, ShouldEqual, NotExistsErr)
+			So(cfg, ShouldBeNil)
+		}
+
+		Convey(`Does not exist`, func() {
+			verifyNotExists(config.StartingEpoch)
+
+			Convey(`Then exists`, func() {
+				create(1)
+				verify(config.StartingEpoch, 1)
+			})
+			Convey(`Then not exists`, func() {
+				clear()
+				verifyNotExists(config.StartingEpoch)
+			})
+		})
+		Convey(`Exists`, func() {
+			create(1)
+			verify(config.StartingEpoch, 1)
+			verify(configVersion(1), 1)
+
+			Convey(`Then modify`, func() {
+				create(2)
+
+				// Verify the old entry is retained.
+				verify(config.StartingEpoch, 1)
+				verify(configVersion(1), 1)
+
+				Convey(`Evict by cache expiry`, func() {
+					// Let the cache expire (note this expires the cache
+					// in the config package, not this package).
+					tc.Add(2 * config.ProjectCacheExpiry)
+
+					verify(config.StartingEpoch, 2)
+					verify(configVersion(1), 2)
+					verify(configVersion(2), 2)
+				})
+				Convey(`Manually evict`, func() {
+					// Force the cache to be cleared by requesting
+					// a more recent version of config.
+					verify(configVersion(2), 2)
+
+					verify(configVersion(1), 2)
+					verify(config.StartingEpoch, 2)
+				})
+			})
+			Convey(`Then retain`, func() {
+				// Let the cache expire (note this expires the cache
+				// in the config package, not this package).
+				tc.Add(2 * config.ProjectCacheExpiry)
+
+				verify(config.StartingEpoch, 1)
+				verify(configVersion(1), 1)
+			})
+			Convey(`Then delete`, func() {
+				clear()
+
+				// Let the cache expire (note this expires the cache
+				// in the config package, not this package).
+				tc.Add(2 * config.ProjectCacheExpiry)
+
+				verifyNotExists(config.StartingEpoch)
+			})
+		})
+	})
+}
+
+func generateProjectConfig(uniqifier int) *configpb.ProjectConfig {
+	cfg := &configpb.Clustering{
+		TestNameRules: []*configpb.TestNameClusteringRule{
+			{
+				Name:         "Google Test (Value-parameterized)",
+				Pattern:      fmt.Sprintf(`^ninja://test_name/%v$`, uniqifier),
+				LikeTemplate: fmt.Sprintf(`ninja://test_name/%v%%`, uniqifier),
+			},
+		},
+	}
+	version := configVersion(uniqifier)
+	projectCfg := &configpb.ProjectConfig{
+		Clustering:  cfg,
+		LastUpdated: timestamppb.New(version),
+	}
+	return projectCfg
+}
+
+func configVersion(uniqifier int) time.Time {
+	return time.Date(2020, 1, 2, 3, 4, 5, uniqifier, time.UTC)
+}
diff --git a/analysis/internal/config/config.go b/analysis/internal/config/config.go
new file mode 100644
index 0000000..c073ccb
--- /dev/null
+++ b/analysis/internal/config/config.go
@@ -0,0 +1,65 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package config implements app-level configs for Weetbix.
+package config
+
+import (
+	"context"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/config"
+	"go.chromium.org/luci/config/server/cfgcache"
+	"go.chromium.org/luci/config/validation"
+)
+
+// Cached service config.
+var cachedCfg = cfgcache.Register(&cfgcache.Entry{
+	Path: "config.cfg",
+	Type: (*configpb.Config)(nil),
+	Validator: func(ctx *validation.Context, msg proto.Message) error {
+		validateConfig(ctx, msg.(*configpb.Config))
+		return nil
+	},
+})
+
+// Update fetches the latest config and puts it into the datastore.
+func Update(ctx context.Context) error {
+	var errs []error
+	if _, err := cachedCfg.Update(ctx, nil); err != nil {
+		errs = append(errs, err)
+	}
+	if err := updateProjects(ctx); err != nil {
+		errs = append(errs, err)
+	}
+	if len(errs) > 0 {
+		return errors.NewMultiError(errs...)
+	}
+	return nil
+}
+
+// Get returns the service-level config.
+func Get(ctx context.Context) (*configpb.Config, error) {
+	cfg, err := cachedCfg.Get(ctx, nil)
+	return cfg.(*configpb.Config), err
+}
+
+// SetTestConfig set test configs in the cachedCfg.
+func SetTestConfig(ctx context.Context, cfg *configpb.Config) error {
+	return cachedCfg.Set(ctx, cfg, &config.Meta{})
+}
diff --git a/analysis/internal/config/config_placeholder.go b/analysis/internal/config/config_placeholder.go
new file mode 100644
index 0000000..a8bd3fa
--- /dev/null
+++ b/analysis/internal/config/config_placeholder.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"go.chromium.org/luci/common/errors"
+	"google.golang.org/protobuf/encoding/prototext"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+var sampleConfigStr = `
+	monorail_hostname: "monorail-test.appspot.com"
+	chunk_gcs_bucket: "my-chunk-bucket"
+	reclustering_workers: 50
+	reclustering_interval_minutes: 5
+`
+
+// CreatePlaceholderConfig returns a new valid Config for testing.
+func CreatePlaceholderConfig() (*configpb.Config, error) {
+	var cfg configpb.Config
+	err := prototext.Unmarshal([]byte(sampleConfigStr), &cfg)
+	if err != nil {
+		return nil, errors.Annotate(err, "Marshaling a test config").Err()
+	}
+	return &cfg, nil
+}
diff --git a/analysis/internal/config/config_test.go b/analysis/internal/config/config_test.go
new file mode 100644
index 0000000..5afd477
--- /dev/null
+++ b/analysis/internal/config/config_test.go
@@ -0,0 +1,38 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"context"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+)
+
+func TestConfig(t *testing.T) {
+	Convey("SetTestConfig updates context config", t, func() {
+		sampleCfg, err := CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		ctx := memory.Use(context.Background())
+		SetTestConfig(ctx, sampleCfg)
+
+		cfg, err := Get(ctx)
+		So(err, ShouldBeNil)
+		So(cfg, ShouldResembleProto, sampleCfg)
+	})
+}
diff --git a/analysis/internal/config/constants.go b/analysis/internal/config/constants.go
new file mode 100644
index 0000000..6a7cc03
--- /dev/null
+++ b/analysis/internal/config/constants.go
@@ -0,0 +1,27 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"regexp"
+)
+
+// ProjectRePattern is the regular expression pattern that matches
+// validly formed LUCI Project names.
+// From https://source.chromium.org/chromium/infra/infra/+/main:luci/appengine/components/components/config/common.py?q=PROJECT_ID_PATTERN
+const ProjectRePattern = `[a-z0-9\-]{1,40}`
+
+// ProjectRe matches validly formed LUCI Project names.
+var ProjectRe = regexp.MustCompile(`^` + ProjectRePattern + `$`)
diff --git a/analysis/internal/config/placeholder_projectconfig_creator.go b/analysis/internal/config/placeholder_projectconfig_creator.go
new file mode 100644
index 0000000..37c36a6
--- /dev/null
+++ b/analysis/internal/config/placeholder_projectconfig_creator.go
@@ -0,0 +1,113 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package config
+
+import (
+	"time"
+
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// createPlaceholderMonorailProject Creates a placeholder Monorail project
+// with default values.
+func createPlaceholderMonorailProject() *configpb.MonorailProject {
+	return &configpb.MonorailProject{
+		Project:         "chromium",
+		PriorityFieldId: 10,
+		Priorities: []*configpb.MonorailPriority{
+			{
+				Priority: "0",
+				Threshold: &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(1500),
+					},
+				},
+			},
+			{
+				Priority: "1",
+				Threshold: &configpb.ImpactThreshold{
+					TestResultsFailed: &configpb.MetricThreshold{
+						OneDay: proto.Int64(500),
+					},
+				},
+			},
+		},
+	}
+}
+
+// Creates a placeholder impact threshold config
+func createPlaceholderImpactThreshold() *configpb.ImpactThreshold {
+	return &configpb.ImpactThreshold{
+		TestResultsFailed: &configpb.MetricThreshold{
+			OneDay: proto.Int64(1000),
+		},
+	}
+}
+
+// Creates a placeholder Clustering config with default values.
+func createPlaceholderClustering() *configpb.Clustering {
+	return &configpb.Clustering{
+		TestNameRules: []*configpb.TestNameClusteringRule{
+			{
+				Name:         "Google Test (Value-parameterized)",
+				Pattern:      `^ninja:(?P<target>[\w/]+:\w+)/` + `(\w+/)?(?P<suite>\w+)\.(?P<case>\w+)/\w+$`,
+				LikeTemplate: `ninja:${target}/%${suite}.${case}%`,
+			},
+			{
+				Name:         "Google Test (Type-parameterized)",
+				Pattern:      `^ninja:(?P<target>[\w/]+:\w+)/` + `(\w+/)?(?P<suite>\w+)/\w+\.(?P<case>\w+)$`,
+				LikeTemplate: `ninja:${target}/%${suite}/%.${case}`,
+			},
+		},
+	}
+}
+
+// Creates a placeholder realms config.
+func createPlaceholderRealms() []*configpb.RealmConfig {
+	return []*configpb.RealmConfig{
+		{
+			Name: "ci",
+			TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+				UpdateTestVariantTask: &configpb.UpdateTestVariantTask{
+					UpdateTestVariantTaskInterval:   durationpb.New(time.Hour),
+					TestVariantStatusUpdateDuration: durationpb.New(6 * time.Hour),
+				},
+				BqExports: []*configpb.BigQueryExport{
+					{
+						Table: &configpb.BigQueryExport_BigQueryTable{
+							CloudProject: "test-hrd",
+							Dataset:      "chromium",
+							Table:        "flaky_test_variants",
+						},
+						Predicate: &atvpb.Predicate{},
+					},
+				},
+			},
+		},
+	}
+}
+
+// Creates a placeholder project config with key "chromium".
+func CreatePlaceholderProjectConfig() *configpb.ProjectConfig {
+	return &configpb.ProjectConfig{
+		Monorail:           createPlaceholderMonorailProject(),
+		BugFilingThreshold: createPlaceholderImpactThreshold(),
+		Realms:             createPlaceholderRealms(),
+		Clustering:         createPlaceholderClustering(),
+	}
+}
diff --git a/analysis/internal/config/project_config.go b/analysis/internal/config/project_config.go
new file mode 100644
index 0000000..2bb3197
--- /dev/null
+++ b/analysis/internal/config/project_config.go
@@ -0,0 +1,398 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/data/caching/lru"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/config"
+	"go.chromium.org/luci/config/cfgclient"
+	"go.chromium.org/luci/config/validation"
+	"go.chromium.org/luci/gae/service/datastore"
+	"go.chromium.org/luci/gae/service/info"
+	"go.chromium.org/luci/server/caching"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// LRU cache, of which only one slot is used (config for all projects
+// is stored in the same slot). We use LRU cache instead of cache slot
+// as we sometimes want to refresh config before it has expired.
+// Only the LRU Cache has the methods to do this.
+var projectsCache = caching.RegisterLRUCache(1)
+
+const projectConfigKind = "weetbix.ProjectConfig"
+
+// ProjectCacheExpiry defines how often project configuration stored
+// in the in-process cache is refreshed from datastore.
+const ProjectCacheExpiry = 1 * time.Minute
+
+// StartingEpoch is the earliest valid config version for a project.
+// It is deliberately different from the timestamp zero value to be
+// discernible from "timestamp not populated" programming errors.
+var StartingEpoch = time.Date(1900, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+// NotExistsErr is returned if no matching configuration could be found
+// for the specified project.
+var NotExistsErr = errors.New("no config exists for the specified project")
+
+var (
+	importAttemptCounter = metric.NewCounter(
+		"weetbix/project_config/import_attempt",
+		"The number of import attempts of project config",
+		nil,
+		// status can be "success" or "failure".
+		field.String("project"), field.String("status"))
+)
+
+type cachedProjectConfig struct {
+	_extra datastore.PropertyMap `gae:"-,extra"`
+	_kind  string                `gae:"$kind,weetbix.ProjectConfig"`
+
+	ID     string      `gae:"$id"` // The name of the project for which the config is.
+	Config []byte      `gae:",noindex"`
+	Meta   config.Meta `gae:",noindex"`
+}
+
+func init() {
+	// Registers validation of the given configuration paths with cfgmodule.
+	validation.Rules.Add("regex:projects/.*", "${appid}.cfg", func(ctx *validation.Context, configSet, path string, content []byte) error {
+		// Discard the returned deserialized message.
+		validateProjectConfigRaw(ctx, string(content))
+		return nil
+	})
+}
+
+// updateProjects fetches fresh project-level configuration from LUCI Config
+// service and stores it in datastore.
+func updateProjects(ctx context.Context) error {
+	// Fetch freshest configs from the LUCI Config.
+	fetchedConfigs, err := fetchLatestProjectConfigs(ctx)
+	if err != nil {
+		return err
+	}
+
+	var errs []error
+	parsedConfigs := make(map[string]*fetchedProjectConfig)
+	for project, fetch := range fetchedConfigs {
+		valCtx := validation.Context{Context: ctx}
+		valCtx.SetFile(fetch.Path)
+		msg := validateProjectConfigRaw(&valCtx, fetch.Content)
+		if err := valCtx.Finalize(); err != nil {
+			blocking := err.(*validation.Error).WithSeverity(validation.Blocking)
+			if blocking != nil {
+				// Continue through validation errors to ensure a validation
+				// error in one project does not affect other projects.
+				errs = append(errs, errors.Annotate(blocking, "validation errors for %q", project).Err())
+				msg = nil
+			}
+		}
+		// We create an entry even for invalid config (where msg == nil),
+		// because we want to signal that config for this project still exists
+		// and existing config should be retained instead of being deleted.
+		parsedConfigs[project] = &fetchedProjectConfig{
+			Config: msg,
+			Meta:   fetch.Meta,
+		}
+	}
+	forceUpdate := false
+	success := true
+	if err := updateStoredConfig(ctx, parsedConfigs, forceUpdate); err != nil {
+		errs = append(errs, err)
+		success = false
+	}
+	// Report success for all projects that passed validation, assuming the
+	// update succeeded.
+	for project, config := range parsedConfigs {
+		status := "success"
+		if !success || config.Config == nil {
+			status = "failure"
+		}
+		importAttemptCounter.Add(ctx, 1, project, status)
+	}
+
+	if len(errs) > 0 {
+		return errors.NewMultiError(errs...)
+	}
+	return nil
+}
+
+type fetchedProjectConfig struct {
+	// config is the project-level configuration, if it has passed validation,
+	// and nil otherwise.
+	Config *configpb.ProjectConfig
+	// meta is populated with config metadata.
+	Meta config.Meta
+}
+
+// updateStoredConfig updates the config stored in datastore. fetchedConfigs
+// contains the new configs to store, setForTesting forces overwrite of existing
+// configuration (ignoring whether the config revision is newer).
+func updateStoredConfig(ctx context.Context, fetchedConfigs map[string]*fetchedProjectConfig, setForTesting bool) error {
+	// Drop out of any existing datastore transactions.
+	ctx = cleanContext(ctx)
+
+	currentConfigs, err := fetchProjectConfigEntities(ctx)
+	if err != nil {
+		return err
+	}
+
+	var errs []error
+	var toPut []*cachedProjectConfig
+	for project, fetch := range fetchedConfigs {
+		if fetch.Config == nil {
+			// Config did not pass validation.
+			continue
+		}
+		cur, ok := currentConfigs[project]
+		if !ok {
+			cur = &cachedProjectConfig{
+				ID: project,
+			}
+		}
+		if !setForTesting && cur.Meta.Revision == fetch.Meta.Revision {
+			logging.Infof(ctx, "Cached config %s is up-to-date at rev %q", cur.ID, cur.Meta.Revision)
+			continue
+		}
+		configToSave := proto.Clone(fetch.Config).(*configpb.ProjectConfig)
+		if !setForTesting {
+			var lastUpdated time.Time
+			if cur.Config != nil {
+				cfg := &configpb.ProjectConfig{}
+				if err := proto.Unmarshal(cur.Config, cfg); err != nil {
+					// Continue through errors to ensure bad config for one project
+					// does not affect others.
+					errs = append(errs, errors.Annotate(err, "unmarshal current config").Err())
+					continue
+				}
+				lastUpdated = cfg.LastUpdated.AsTime()
+			}
+			// ContentHash updated implies Revision updated, but Revision updated
+			// does not imply ContentHash updated. To avoid unnecessarily
+			// incrementing the last updated time (which triggers re-clustering),
+			// only update it if content has changed.
+			if cur.Meta.ContentHash != fetch.Meta.ContentHash {
+				// Content updated. Update version.
+				now := clock.Now(ctx)
+				if !now.After(lastUpdated) {
+					errs = append(errs, errors.New("old config version is after current time"))
+					continue
+				}
+				lastUpdated = now
+			}
+			configToSave.LastUpdated = timestamppb.New(lastUpdated)
+		}
+		// else: use LastUpdated time provided in SetTestProjectConfig call.
+
+		blob, err := proto.Marshal(configToSave)
+		if err != nil {
+			// Continue through errors to ensure bad config for one project
+			// does not affect others.
+			errs = append(errs, errors.Annotate(err, "marshal fetched config").Err())
+			continue
+		}
+		logging.Infof(ctx, "Updating cached config %s: %q -> %q", cur.ID, cur.Meta.Revision, fetch.Meta.Revision)
+		toPut = append(toPut, &cachedProjectConfig{
+			ID:     cur.ID,
+			Config: blob,
+			Meta:   fetch.Meta,
+		})
+	}
+	if err := datastore.Put(ctx, toPut); err != nil {
+		errs = append(errs, errors.Annotate(err, "updating project configs").Err())
+	}
+
+	var toDelete []*datastore.Key
+	for project, cur := range currentConfigs {
+		if _, ok := fetchedConfigs[project]; ok {
+			continue
+		}
+		toDelete = append(toDelete, datastore.KeyForObj(ctx, cur))
+	}
+
+	if err := datastore.Delete(ctx, toDelete); err != nil {
+		errs = append(errs, errors.Annotate(err, "deleting stale project configs").Err())
+	}
+
+	if len(errs) > 0 {
+		return errors.NewMultiError(errs...)
+	}
+	return nil
+}
+
+func fetchLatestProjectConfigs(ctx context.Context) (map[string]config.Config, error) {
+	configs, err := cfgclient.Client(ctx).GetProjectConfigs(ctx, "${appid}.cfg", false)
+	if err != nil {
+		return nil, err
+	}
+	result := make(map[string]config.Config)
+	for _, cfg := range configs {
+		project := cfg.ConfigSet.Project()
+		if project != "" {
+			result[project] = cfg
+		}
+	}
+	return result, nil
+}
+
+// fetchProjectConfigEntities retrieves project configuration entities
+// from datastore, including metadata.
+func fetchProjectConfigEntities(ctx context.Context) (map[string]*cachedProjectConfig, error) {
+	var configs []*cachedProjectConfig
+	err := datastore.GetAll(ctx, datastore.NewQuery(projectConfigKind), &configs)
+	if err != nil {
+		return nil, errors.Annotate(err, "fetching project configs from datastore").Err()
+	}
+	result := make(map[string]*cachedProjectConfig)
+	for _, cfg := range configs {
+		result[cfg.ID] = cfg
+	}
+	return result, nil
+}
+
+// projectsWithMinimumVersion retrieves projects configurations, with
+// the specified project at at least the specified minimumVersion.
+// If no particular minimum version is desired, specify a project of ""
+// or a minimumVersion of time.Time{}.
+func projectsWithMinimumVersion(ctx context.Context, project string, minimumVersion time.Time) (map[string]*configpb.ProjectConfig, error) {
+	var pc map[string]*configpb.ProjectConfig
+	var err error
+	cache := projectsCache.LRU(ctx)
+	if cache == nil {
+		// A fallback useful in unit tests that may not have the process cache
+		// available. Production environments usually have the cache installed
+		// by the framework code that initializes the root context.
+		pc, err = fetchProjects(ctx)
+		if err != nil {
+			return nil, err
+		}
+	} else {
+		value, _ := projectsCache.LRU(ctx).Mutate(ctx, "projects", func(it *lru.Item) *lru.Item {
+			var pc map[string]*configpb.ProjectConfig
+			if it != nil {
+				pc = it.Value.(map[string]*configpb.ProjectConfig)
+				projectCfg, ok := pc[project]
+				if project == "" || (ok && !projectCfg.LastUpdated.AsTime().Before(minimumVersion)) {
+					// Projects contains the specified project at the given minimum version.
+					// There is no need to update it.
+					return it
+				}
+			}
+			if pc, err = fetchProjects(ctx); err != nil {
+				// Error refreshing config. Keep existing entry (if any).
+				return it
+			}
+			return &lru.Item{
+				Value: pc,
+				Exp:   ProjectCacheExpiry,
+			}
+		})
+		if err != nil {
+			return nil, err
+		}
+		pc = value.(map[string]*configpb.ProjectConfig)
+	}
+
+	projectCfg, ok := pc[project]
+	if project != "" && (ok && projectCfg.LastUpdated.AsTime().Before(minimumVersion)) {
+		return nil, fmt.Errorf("could not obtain projects configuration with project %s at minimum version (%v)", project, minimumVersion)
+	}
+	return pc, nil
+}
+
+// Projects returns all project configurations, in a map by project name.
+// Uses in-memory cache to avoid hitting datastore all the time.
+func Projects(ctx context.Context) (map[string]*configpb.ProjectConfig, error) {
+	return projectsWithMinimumVersion(ctx, "", time.Time{})
+}
+
+// fetchProjects retrieves all project configurations from datastore.
+func fetchProjects(ctx context.Context) (map[string]*configpb.ProjectConfig, error) {
+	ctx = cleanContext(ctx)
+
+	cachedCfgs, err := fetchProjectConfigEntities(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "fetching cached config").Err()
+	}
+	result := make(map[string]*configpb.ProjectConfig)
+	for project, cached := range cachedCfgs {
+		cfg := &configpb.ProjectConfig{}
+		if err := proto.Unmarshal(cached.Config, cfg); err != nil {
+			return nil, errors.Annotate(err, "unmarshalling cached config").Err()
+		}
+		result[project] = cfg
+	}
+	return result, nil
+}
+
+// cleanContext returns a context with datastore using the default namespace
+// and not using transactions.
+func cleanContext(ctx context.Context) context.Context {
+	return datastore.WithoutTransaction(info.MustNamespace(ctx, ""))
+}
+
+// SetTestProjectConfig sets test project configuration in datastore.
+// It should be used from unit/integration tests only.
+func SetTestProjectConfig(ctx context.Context, cfg map[string]*configpb.ProjectConfig) error {
+	fetchedConfigs := make(map[string]*fetchedProjectConfig)
+	for project, pcfg := range cfg {
+		fetchedConfigs[project] = &fetchedProjectConfig{
+			Config: pcfg,
+			Meta:   config.Meta{},
+		}
+	}
+	setForTesting := true
+	if err := updateStoredConfig(ctx, fetchedConfigs, setForTesting); err != nil {
+		return err
+	}
+	testable := datastore.GetTestable(ctx)
+	if testable == nil {
+		return errors.New("SetTestProjectConfig should only be used with testable datastore implementations")
+	}
+	// An up-to-date index is required for fetch to retrieve the project
+	// entities we just saved.
+	testable.CatchupIndexes()
+	return nil
+}
+
+// Project returns the configuration of the requested project.
+func Project(ctx context.Context, project string) (*configpb.ProjectConfig, error) {
+	return ProjectWithMinimumVersion(ctx, project, time.Time{})
+}
+
+// ProjectWithMinimumVersion returns the configuration of the requested
+// project, which has a LastUpdated time of at least minimumVersion.
+// This bypasses the in-process cache if the cached version is older
+// than the specified version.
+func ProjectWithMinimumVersion(ctx context.Context, project string, minimumVersion time.Time) (*configpb.ProjectConfig, error) {
+	configs, err := projectsWithMinimumVersion(ctx, project, minimumVersion)
+	if err != nil {
+		return nil, err
+	}
+	if c, ok := configs[project]; ok {
+		return c, nil
+	}
+	return nil, NotExistsErr
+}
diff --git a/analysis/internal/config/project_config_test.go b/analysis/internal/config/project_config_test.go
new file mode 100644
index 0000000..4c26f95
--- /dev/null
+++ b/analysis/internal/config/project_config_test.go
@@ -0,0 +1,273 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/config"
+	"go.chromium.org/luci/config/cfgclient"
+	cfgmem "go.chromium.org/luci/config/impl/memory"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/gae/service/datastore"
+	"go.chromium.org/luci/server/caching"
+	"google.golang.org/protobuf/encoding/prototext"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+var textPBMultiline = prototext.MarshalOptions{
+	Multiline: true,
+}
+
+func TestProjectConfig(t *testing.T) {
+	t.Parallel()
+
+	Convey("SetTestProjectConfig updates context config", t, func() {
+		projectA := CreatePlaceholderProjectConfig()
+		projectA.LastUpdated = timestamppb.New(time.Now())
+		configs := make(map[string]*configpb.ProjectConfig)
+		configs["a"] = projectA
+
+		ctx := memory.Use(context.Background())
+		SetTestProjectConfig(ctx, configs)
+
+		cfg, err := Projects(ctx)
+
+		So(err, ShouldBeNil)
+		So(len(cfg), ShouldEqual, 1)
+		So(cfg["a"], ShouldResembleProto, projectA)
+	})
+
+	Convey("With mocks", t, func() {
+		projectA := CreatePlaceholderProjectConfig()
+		projectB := CreatePlaceholderProjectConfig()
+		projectB.Monorail.PriorityFieldId = 1
+
+		configs := map[config.Set]cfgmem.Files{
+			"projects/a": {"${appid}.cfg": textPBMultiline.Format(projectA)},
+			"projects/b": {"${appid}.cfg": textPBMultiline.Format(projectB)},
+		}
+
+		ctx := memory.Use(context.Background())
+		ctx, tc := testclock.UseTime(ctx, testclock.TestTimeUTC)
+		ctx = cfgclient.Use(ctx, cfgmem.New(configs))
+		ctx = caching.WithEmptyProcessCache(ctx)
+
+		Convey("Update works", func() {
+			// Initial update.
+			creationTime := clock.Now(ctx)
+			err := updateProjects(ctx)
+			So(err, ShouldBeNil)
+			datastore.GetTestable(ctx).CatchupIndexes()
+
+			// Get works.
+			projects, err := Projects(ctx)
+			So(err, ShouldBeNil)
+			So(len(projects), ShouldEqual, 2)
+			So(projects["a"], ShouldResembleProto, withLastUpdated(projectA, creationTime))
+			So(projects["b"], ShouldResembleProto, withLastUpdated(projectB, creationTime))
+
+			tc.Add(1 * time.Second)
+
+			// Noop update.
+			err = updateProjects(ctx)
+			So(err, ShouldBeNil)
+			datastore.GetTestable(ctx).CatchupIndexes()
+
+			tc.Add(1 * time.Second)
+
+			// Real update.
+			projectC := CreatePlaceholderProjectConfig()
+			newProjectB := CreatePlaceholderProjectConfig()
+			newProjectB.Monorail.PriorityFieldId = 2
+			delete(configs, "projects/a")
+			configs["projects/b"]["${appid}.cfg"] = textPBMultiline.Format(newProjectB)
+			configs["projects/c"] = cfgmem.Files{
+				"${appid}.cfg": textPBMultiline.Format(projectC),
+			}
+			updateTime := clock.Now(ctx)
+			err = updateProjects(ctx)
+			So(err, ShouldBeNil)
+			datastore.GetTestable(ctx).CatchupIndexes()
+
+			// Fetch returns the new value right away.
+			projects, err = fetchProjects(ctx)
+			So(err, ShouldBeNil)
+			So(len(projects), ShouldEqual, 2)
+			So(projects["b"], ShouldResembleProto, withLastUpdated(newProjectB, updateTime))
+			So(projects["c"], ShouldResembleProto, withLastUpdated(projectC, updateTime))
+
+			// Get still uses in-memory cached copy.
+			projects, err = Projects(ctx)
+			So(err, ShouldBeNil)
+			So(len(projects), ShouldEqual, 2)
+			So(projects["a"], ShouldResembleProto, withLastUpdated(projectA, creationTime))
+			So(projects["b"], ShouldResembleProto, withLastUpdated(projectB, creationTime))
+
+			Convey("Expedited cache eviction", func() {
+				projectB, err = ProjectWithMinimumVersion(ctx, "b", updateTime)
+				So(err, ShouldBeNil)
+				So(projectB, ShouldResembleProto, withLastUpdated(newProjectB, updateTime))
+			})
+			Convey("Natural cache eviction", func() {
+				// Time passes, in-memory cached copy expires.
+				tc.Add(2 * time.Minute)
+
+				// Get returns the new value now too.
+				projects, err = Projects(ctx)
+				So(err, ShouldBeNil)
+				So(len(projects), ShouldEqual, 2)
+				So(projects["b"], ShouldResembleProto, withLastUpdated(newProjectB, updateTime))
+				So(projects["c"], ShouldResembleProto, withLastUpdated(projectC, updateTime))
+
+				// Time passes, in-memory cached copy expires.
+				tc.Add(2 * time.Minute)
+
+				// Get returns the same value.
+				projects, err = Projects(ctx)
+				So(err, ShouldBeNil)
+				So(len(projects), ShouldEqual, 2)
+				So(projects["b"], ShouldResembleProto, withLastUpdated(newProjectB, updateTime))
+				So(projects["c"], ShouldResembleProto, withLastUpdated(projectC, updateTime))
+			})
+		})
+
+		Convey("Validation works", func() {
+			configs["projects/b"]["${appid}.cfg"] = `bad data`
+			creationTime := clock.Now(ctx)
+			err := updateProjects(ctx)
+			datastore.GetTestable(ctx).CatchupIndexes()
+			So(err, ShouldErrLike, "validation errors")
+
+			// Validation for project A passed and project is
+			// available, validation for project B failed
+			// as is not available.
+			projects, err := Projects(ctx)
+			So(err, ShouldBeNil)
+			So(len(projects), ShouldEqual, 1)
+			So(projects["a"], ShouldResembleProto, withLastUpdated(projectA, creationTime))
+		})
+
+		Convey("Update retains existing config if new config is invalid", func() {
+			// Initial update.
+			creationTime := clock.Now(ctx)
+			err := updateProjects(ctx)
+			So(err, ShouldBeNil)
+			datastore.GetTestable(ctx).CatchupIndexes()
+
+			// Get works.
+			projects, err := Projects(ctx)
+			So(err, ShouldBeNil)
+			So(len(projects), ShouldEqual, 2)
+			So(projects["a"], ShouldResembleProto, withLastUpdated(projectA, creationTime))
+			So(projects["b"], ShouldResembleProto, withLastUpdated(projectB, creationTime))
+
+			tc.Add(1 * time.Second)
+
+			// Attempt to update with an invalid config for project B.
+			newProjectA := CreatePlaceholderProjectConfig()
+			newProjectA.Monorail.Project = "new-project-a"
+			newProjectB := CreatePlaceholderProjectConfig()
+			newProjectB.Monorail.Project = ""
+			configs["projects/a"]["${appid}.cfg"] = textPBMultiline.Format(newProjectA)
+			configs["projects/b"]["${appid}.cfg"] = textPBMultiline.Format(newProjectB)
+			updateTime := clock.Now(ctx)
+			err = updateProjects(ctx)
+			So(err, ShouldErrLike, "validation errors")
+			datastore.GetTestable(ctx).CatchupIndexes()
+
+			// Time passes, in-memory cached copy expires.
+			tc.Add(2 * time.Minute)
+
+			// Get returns the new configuration A and the old
+			// configuration for B. This ensures an attempt to push an invalid
+			// config does not result in a service outage for that project.
+			projects, err = Projects(ctx)
+			So(err, ShouldBeNil)
+			So(len(projects), ShouldEqual, 2)
+			So(projects["a"], ShouldResembleProto, withLastUpdated(newProjectA, updateTime))
+			So(projects["b"], ShouldResembleProto, withLastUpdated(projectB, creationTime))
+		})
+	})
+}
+
+// withLastUpdated returns a copy of the given ProjectConfig with the
+// specified LastUpdated time set.
+func withLastUpdated(cfg *configpb.ProjectConfig, lastUpdated time.Time) *configpb.ProjectConfig {
+	result := proto.Clone(cfg).(*configpb.ProjectConfig)
+	result.LastUpdated = timestamppb.New(lastUpdated)
+	return result
+}
+
+func TestProject(t *testing.T) {
+	t.Parallel()
+
+	Convey("Project", t, func() {
+		pjChromium := CreatePlaceholderProjectConfig()
+		configs := map[string]*configpb.ProjectConfig{
+			"chromium": pjChromium,
+		}
+
+		ctx := memory.Use(context.Background())
+		SetTestProjectConfig(ctx, configs)
+
+		Convey("success", func() {
+			pj, err := Project(ctx, "chromium")
+			So(err, ShouldBeNil)
+			So(pj, ShouldResembleProto, pjChromium)
+		})
+
+		Convey("not found", func() {
+			pj, err := Project(ctx, "random")
+			So(err, ShouldEqual, NotExistsErr)
+			So(pj, ShouldBeNil)
+		})
+	})
+}
+
+func TestRealm(t *testing.T) {
+	t.Parallel()
+
+	Convey("Realm", t, func() {
+		pj := CreatePlaceholderProjectConfig()
+		configs := map[string]*configpb.ProjectConfig{
+			"chromium": pj,
+		}
+
+		ctx := memory.Use(context.Background())
+		SetTestProjectConfig(ctx, configs)
+
+		Convey("success", func() {
+			rj, err := Realm(ctx, "chromium:ci")
+			So(err, ShouldBeNil)
+			So(rj, ShouldResembleProto, pj.Realms[0])
+		})
+
+		Convey("not found", func() {
+			rj, err := Realm(ctx, "chromium:random")
+			So(err, ShouldEqual, RealmNotExistsErr)
+			So(rj, ShouldBeNil)
+		})
+	})
+}
diff --git a/analysis/internal/config/realm_config.go b/analysis/internal/config/realm_config.go
new file mode 100644
index 0000000..9c3a7d0
--- /dev/null
+++ b/analysis/internal/config/realm_config.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"context"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/auth/realms"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+// RealmNotExistsErr is returned if no configuration could be found
+// for the specified realm.
+var RealmNotExistsErr = errors.New("no config found for realm")
+
+// Realm returns the configurations of the requested realm.
+// If no configuration can be found for the realm, returns
+// RealmNotExistsErr.
+func Realm(ctx context.Context, global string) (*configpb.RealmConfig, error) {
+	project, realm := realms.Split(global)
+	pc, err := Project(ctx, project)
+	if err != nil {
+		if err == NotExistsErr {
+			return nil, RealmNotExistsErr
+		}
+		return nil, err
+	}
+	for _, rc := range pc.GetRealms() {
+		if rc.Name == realm {
+			return rc, nil
+		}
+	}
+	return nil, RealmNotExistsErr
+}
diff --git a/analysis/internal/config/validate.go b/analysis/internal/config/validate.go
new file mode 100644
index 0000000..036bb8d
--- /dev/null
+++ b/analysis/internal/config/validate.go
@@ -0,0 +1,439 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"fmt"
+	"regexp"
+
+	luciproto "go.chromium.org/luci/common/proto"
+	"go.chromium.org/luci/config/validation"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname/rules"
+	"go.chromium.org/luci/analysis/pbutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+const maxHysteresisPercent = 1000
+
+var (
+	// https://cloud.google.com/storage/docs/naming-buckets
+	bucketRE = regexp.MustCompile(`^[a-z0-9][a-z0-9\-_.]{1,220}[a-z0-9]$`)
+
+	// From https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/project/project_constants.py;l=13.
+	monorailProjectRE = regexp.MustCompile(`^[a-z0-9][-a-z0-9]{0,61}[a-z0-9]$`)
+
+	// https://source.chromium.org/chromium/infra/infra/+/main:luci/appengine/auth_service/proto/realms_config.proto;l=85;drc=04e290f764a293d642d287b0118e9880df4afb35
+	realmRE = regexp.MustCompile(`^[a-z0-9_\.\-/]{1,400}$`)
+
+	// Matches valid prefixes to use when displaying bugs.
+	// E.g. "crbug.com", "fxbug.dev".
+	prefixRE = regexp.MustCompile(`^[a-z0-9\-.]{0,64}$`)
+
+	// hostnameRE excludes most invalid hostnames.
+	hostnameRE = regexp.MustCompile(`^[a-z][a-z9-9\-.]{0,62}[a-z]$`)
+
+	// nameRE matches valid rule names.
+	ruleNameRE = regexp.MustCompile(`^[a-zA-Z0-9\-(), ]+$`)
+
+	// anyRE matches any input.
+	anyRE = regexp.MustCompile(`^.*$`)
+
+	// Patterns for BigQuery table.
+	// https://cloud.google.com/resource-manager/docs/creating-managing-projects
+	cloudProjectRE = regexp.MustCompile(`^[a-z][a-z0-9\-]{4,28}[a-z0-9]$`)
+	// https://cloud.google.com/bigquery/docs/datasets#dataset-naming
+	datasetRE = regexp.MustCompile(`^[a-zA-Z0-9_]*$`)
+	// https://cloud.google.com/bigquery/docs/tables#table_naming
+	tableRE = regexp.MustCompile(`^[\p{L}\p{M}\p{N}\p{Pc}\p{Pd}\p{Zs}]*$`)
+)
+
+func validateConfig(ctx *validation.Context, cfg *configpb.Config) {
+	validateHostname(ctx, "monorail_hostname", cfg.MonorailHostname, false /*optional*/)
+	validateStringConfig(ctx, "chunk_gcs_bucket", cfg.ChunkGcsBucket, bucketRE)
+	// Limit to default max_concurrent_requests of 1000.
+	// https://cloud.google.com/appengine/docs/standard/go111/config/queueref
+	validateIntegerConfig(ctx, "reclustering_workers", cfg.ReclusteringWorkers, 1000)
+	// Limit within GAE autoscaling request timeout of 10 minutes.
+	// https://cloud.google.com/appengine/docs/standard/python/how-instances-are-managed
+	validateIntegerConfig(ctx, "reclustering_interval_minutes", cfg.ReclusteringIntervalMinutes, 9)
+}
+
+func validateHostname(ctx *validation.Context, name, hostname string, optional bool) {
+	ctx.Enter(name)
+	if hostname == "" {
+		if !optional {
+			ctx.Errorf("empty value is not allowed")
+		}
+	} else if !hostnameRE.MatchString(hostname) {
+		ctx.Errorf("invalid hostname: %q", hostname)
+	}
+	ctx.Exit()
+}
+
+func validateStringConfig(ctx *validation.Context, name, cfg string, re *regexp.Regexp) {
+	ctx.Enter(name)
+	switch err := pbutil.ValidateWithRe(re, cfg); err {
+	case pbutil.Unspecified:
+		ctx.Errorf("empty %s is not allowed", name)
+	case pbutil.DoesNotMatch:
+		ctx.Errorf("invalid %s: %q", name, cfg)
+	}
+	ctx.Exit()
+}
+
+func validateIntegerConfig(ctx *validation.Context, name string, cfg, max int64) {
+	ctx.Enter(name)
+	defer ctx.Exit()
+
+	if cfg < 0 {
+		ctx.Errorf("value is less than zero")
+	}
+	if cfg >= max {
+		ctx.Errorf("value is greater than %v", max)
+	}
+}
+
+func validateDuration(ctx *validation.Context, name string, du *durationpb.Duration) {
+	ctx.Enter(name)
+	defer ctx.Exit()
+
+	switch {
+	case du == nil:
+		ctx.Errorf("empty %s is not allowed", name)
+	case du.CheckValid() != nil:
+		ctx.Errorf("%s is invalid", name)
+	case du.AsDuration() < 0:
+		ctx.Errorf("%s is less than 0", name)
+	}
+}
+
+func validateUpdateTestVariantTask(ctx *validation.Context, utCfg *configpb.UpdateTestVariantTask) {
+	ctx.Enter("update_test_variant")
+	defer ctx.Exit()
+	if utCfg == nil {
+		return
+	}
+	validateDuration(ctx, "interval", utCfg.UpdateTestVariantTaskInterval)
+	validateDuration(ctx, "duration", utCfg.TestVariantStatusUpdateDuration)
+}
+
+func validateBigQueryTable(ctx *validation.Context, tCfg *configpb.BigQueryExport_BigQueryTable) {
+	ctx.Enter("table")
+	defer ctx.Exit()
+	if tCfg == nil {
+		ctx.Errorf("empty bigquery table is not allowed")
+		return
+	}
+	validateStringConfig(ctx, "cloud_project", tCfg.CloudProject, cloudProjectRE)
+	validateStringConfig(ctx, "dataset", tCfg.Dataset, datasetRE)
+	validateStringConfig(ctx, "table_name", tCfg.Table, tableRE)
+}
+
+func validateBigQueryExport(ctx *validation.Context, bqCfg *configpb.BigQueryExport) {
+	ctx.Enter("bigquery_export")
+	defer ctx.Exit()
+	if bqCfg == nil {
+		return
+	}
+	validateBigQueryTable(ctx, bqCfg.Table)
+	if bqCfg.GetPredicate() == nil {
+		return
+	}
+	if err := pbutil.ValidateAnalyzedTestVariantPredicate(bqCfg.Predicate); err != nil {
+		ctx.Errorf(fmt.Sprintf("%s", err))
+	}
+}
+
+func validateTestVariantAnalysisConfig(ctx *validation.Context, tvCfg *configpb.TestVariantAnalysisConfig) {
+	ctx.Enter("test_variant")
+	defer ctx.Exit()
+	if tvCfg == nil {
+		return
+	}
+	validateUpdateTestVariantTask(ctx, tvCfg.UpdateTestVariantTask)
+	for _, bqe := range tvCfg.BqExports {
+		validateBigQueryExport(ctx, bqe)
+	}
+}
+
+func validateRealmConfig(ctx *validation.Context, rCfg *configpb.RealmConfig) {
+	ctx.Enter(fmt.Sprintf("realm %s", rCfg.Name))
+	defer ctx.Exit()
+
+	validateStringConfig(ctx, "realm_name", rCfg.Name, realmRE)
+	validateTestVariantAnalysisConfig(ctx, rCfg.TestVariantAnalysis)
+}
+
+// validateProjectConfigRaw deserializes the project-level config message
+// and passes it through the validator.
+func validateProjectConfigRaw(ctx *validation.Context, content string) *configpb.ProjectConfig {
+	msg := &configpb.ProjectConfig{}
+	if err := luciproto.UnmarshalTextML(content, msg); err != nil {
+		ctx.Errorf("failed to unmarshal as text proto: %s", err)
+		return nil
+	}
+	ValidateProjectConfig(ctx, msg)
+	return msg
+}
+
+func ValidateProjectConfig(ctx *validation.Context, cfg *configpb.ProjectConfig) {
+	validateMonorail(ctx, cfg.Monorail, cfg.BugFilingThreshold)
+	validateImpactThreshold(ctx, cfg.BugFilingThreshold, "bug_filing_threshold")
+	for _, rCfg := range cfg.Realms {
+		validateRealmConfig(ctx, rCfg)
+	}
+	validateClustering(ctx, cfg.Clustering)
+}
+
+func validateMonorail(ctx *validation.Context, cfg *configpb.MonorailProject, bugFilingThres *configpb.ImpactThreshold) {
+	ctx.Enter("monorail")
+	defer ctx.Exit()
+
+	if cfg == nil {
+		ctx.Errorf("monorail must be specified")
+		return
+	}
+
+	validateStringConfig(ctx, "project", cfg.Project, monorailProjectRE)
+	validateDefaultFieldValues(ctx, cfg.DefaultFieldValues)
+	validateFieldID(ctx, cfg.PriorityFieldId, "priority_field_id")
+	validatePriorities(ctx, cfg.Priorities, bugFilingThres)
+	validatePriorityHysteresisPercent(ctx, cfg.PriorityHysteresisPercent)
+	validateDisplayPrefix(ctx, cfg.DisplayPrefix)
+	validateHostname(ctx, "monorail_hostname", cfg.MonorailHostname, true /*optional*/)
+}
+
+func validateDefaultFieldValues(ctx *validation.Context, fvs []*configpb.MonorailFieldValue) {
+	ctx.Enter("default_field_values")
+	for i, fv := range fvs {
+		ctx.Enter("[%v]", i)
+		validateFieldValue(ctx, fv)
+		ctx.Exit()
+	}
+	ctx.Exit()
+}
+
+func validateFieldID(ctx *validation.Context, fieldID int64, fieldName string) {
+	ctx.Enter(fieldName)
+	if fieldID < 0 {
+		ctx.Errorf("value must be non-negative")
+	}
+	ctx.Exit()
+}
+
+func validateFieldValue(ctx *validation.Context, fv *configpb.MonorailFieldValue) {
+	validateFieldID(ctx, fv.GetFieldId(), "field_id")
+	// No validation applies to field value.
+}
+
+func validatePriorities(ctx *validation.Context, ps []*configpb.MonorailPriority, bugFilingThres *configpb.ImpactThreshold) {
+	ctx.Enter("priorities")
+	if len(ps) == 0 {
+		ctx.Errorf("at least one monorail priority must be specified")
+	}
+	for i, p := range ps {
+		ctx.Enter("[%v]", i)
+		validatePriority(ctx, p)
+		if i == len(ps)-1 {
+			// The lowest priority threshold must be satisfied by
+			// the bug-filing threshold. This ensures that bugs meeting the
+			// bug-filing threshold meet the bug keep-open threshold.
+			validatePrioritySatisfiedByBugFilingThreshold(ctx, p, bugFilingThres)
+		}
+		ctx.Exit()
+	}
+	ctx.Exit()
+}
+
+func validatePriority(ctx *validation.Context, p *configpb.MonorailPriority) {
+	validatePriorityValue(ctx, p.Priority)
+	validateImpactThreshold(ctx, p.Threshold, "threshold")
+}
+
+func validatePrioritySatisfiedByBugFilingThreshold(ctx *validation.Context, p *configpb.MonorailPriority, bugFilingThres *configpb.ImpactThreshold) {
+	ctx.Enter("threshold")
+	defer ctx.Exit()
+	t := p.Threshold
+	if t == nil || bugFilingThres == nil {
+		// Priority without threshold and no bug filing threshold specified
+		// are already reported as errors elsewhere.
+		return
+	}
+	validateBugFilingThresholdSatisfiesMetricThresold(ctx, t.CriticalFailuresExonerated, bugFilingThres.CriticalFailuresExonerated, "critical_failures_exonerated")
+	validateBugFilingThresholdSatisfiesMetricThresold(ctx, t.TestResultsFailed, bugFilingThres.TestResultsFailed, "test_results_failed")
+	validateBugFilingThresholdSatisfiesMetricThresold(ctx, t.TestRunsFailed, bugFilingThres.TestRunsFailed, "test_runs_failed")
+	validateBugFilingThresholdSatisfiesMetricThresold(ctx, t.PresubmitRunsFailed, bugFilingThres.PresubmitRunsFailed, "presubmit_runs_failed")
+}
+
+func validatePriorityValue(ctx *validation.Context, value string) {
+	ctx.Enter("priority")
+	// Although it is possible to allow the priority field to be empty, it
+	// would be rather unusual for a project to set itself up this way. For
+	// now, prefer to enforce priority values are non-empty as this will pick
+	// likely configuration errors.
+	if value == "" {
+		ctx.Errorf("empty value is not allowed")
+	}
+	ctx.Exit()
+}
+
+func validateImpactThreshold(ctx *validation.Context, t *configpb.ImpactThreshold, fieldName string) {
+	ctx.Enter(fieldName)
+	defer ctx.Exit()
+
+	if t == nil {
+		ctx.Errorf("impact thresolds must be specified")
+		return
+	}
+
+	validateMetricThreshold(ctx, t.CriticalFailuresExonerated, "critical_failures_exonerated")
+	validateMetricThreshold(ctx, t.TestResultsFailed, "test_results_failed")
+	validateMetricThreshold(ctx, t.TestRunsFailed, "test_runs_failed")
+	validateMetricThreshold(ctx, t.PresubmitRunsFailed, "presubmit_runs_failed")
+}
+
+func validateMetricThreshold(ctx *validation.Context, t *configpb.MetricThreshold, fieldName string) {
+	ctx.Enter(fieldName)
+	defer ctx.Exit()
+
+	if t == nil {
+		// Not specified.
+		return
+	}
+
+	validateThresholdValue(ctx, t.OneDay, "one_day")
+	validateThresholdValue(ctx, t.ThreeDay, "three_day")
+	validateThresholdValue(ctx, t.SevenDay, "seven_day")
+}
+
+func validatePriorityHysteresisPercent(ctx *validation.Context, value int64) {
+	ctx.Enter("priority_hysteresis_percent")
+	if value > maxHysteresisPercent {
+		ctx.Errorf("value must not exceed %v percent", maxHysteresisPercent)
+	}
+	if value < 0 {
+		ctx.Errorf("value must not be negative")
+	}
+	ctx.Exit()
+}
+
+func validateThresholdValue(ctx *validation.Context, value *int64, fieldName string) {
+	ctx.Enter(fieldName)
+	if value != nil && *value < 0 {
+		ctx.Errorf("value must be non-negative")
+	}
+	if value != nil && *value >= 1000*1000 {
+		ctx.Errorf("value must be less than one million")
+	}
+	ctx.Exit()
+}
+
+func validateBugFilingThresholdSatisfiesMetricThresold(ctx *validation.Context, threshold *configpb.MetricThreshold, bugFilingThres *configpb.MetricThreshold, fieldName string) {
+	ctx.Enter(fieldName)
+	defer ctx.Exit()
+	if threshold == nil {
+		threshold = &configpb.MetricThreshold{}
+	}
+	if bugFilingThres == nil {
+		// Bugs are not filed based on this metric. So
+		// we do not need to check that bugs filed
+		// based on this metric will stay open.
+		return
+	}
+
+	// If the bug-filing threshold is:
+	//  - Presubmit Runs Failed (1-day) > 3
+	// And the keep-open threshold is:
+	//  - Presubmit Runs Failed (7-day) > 1
+	// The former actually implies the latter, even though the time periods
+	// are different. Reflect that in the validation here, by calculating
+	// the effective thresholds for one and three days that are sufficient
+	// to keep a bug open.
+	oneDayThreshold := minOfThresholds(threshold.OneDay, threshold.ThreeDay, threshold.SevenDay)
+	threeDayThreshold := minOfThresholds(threshold.ThreeDay, threshold.SevenDay)
+
+	validateBugFilingThresholdSatisfiesThresold(ctx, oneDayThreshold, bugFilingThres.OneDay, "one_day")
+	validateBugFilingThresholdSatisfiesThresold(ctx, threeDayThreshold, bugFilingThres.ThreeDay, "three_day")
+	validateBugFilingThresholdSatisfiesThresold(ctx, threshold.SevenDay, bugFilingThres.SevenDay, "seven_day")
+}
+
+func minOfThresholds(thresholds ...*int64) *int64 {
+	var result *int64
+	for _, t := range thresholds {
+		if t != nil && (result == nil || *t < *result) {
+			result = t
+		}
+	}
+	return result
+}
+
+func validateBugFilingThresholdSatisfiesThresold(ctx *validation.Context, threshold *int64, bugFilingThres *int64, fieldName string) {
+	ctx.Enter(fieldName)
+	defer ctx.Exit()
+	if bugFilingThres == nil {
+		// Bugs are not filed based on this threshold.
+		return
+	}
+	if *bugFilingThres < 0 {
+		// The bug-filing threshold is invalid. This is already reported as an
+		// error elsewhere.
+		return
+	}
+	// If a bug may be filed at a particular threshold, it must also be
+	// allowed to stay open at that threshold.
+	if threshold == nil {
+		ctx.Errorf("%s threshold must be set, with a value of at most %v (the configured bug-filing threshold). This ensures that bugs which are filed meet the criteria to stay open", fieldName, *bugFilingThres)
+	} else if *threshold > *bugFilingThres {
+		ctx.Errorf("value must be at most %v (the configured bug-filing threshold). This ensures that bugs which are filed meet the criteria to stay open", *bugFilingThres)
+	}
+}
+
+func validateDisplayPrefix(ctx *validation.Context, prefix string) {
+	ctx.Enter(prefix)
+	defer ctx.Exit()
+	if !prefixRE.MatchString(prefix) {
+		ctx.Errorf("invalid display prefix: %q", prefix)
+	}
+}
+
+func validateClustering(ctx *validation.Context, ca *configpb.Clustering) {
+	ctx.Enter("clustering")
+	defer ctx.Exit()
+
+	if ca == nil {
+		return
+	}
+	for i, r := range ca.TestNameRules {
+		ctx.Enter("[%v]", i)
+		validateTestNameRule(ctx, r)
+		ctx.Exit()
+	}
+}
+
+func validateTestNameRule(ctx *validation.Context, r *configpb.TestNameClusteringRule) {
+	validateStringConfig(ctx, "name", r.Name, ruleNameRE)
+
+	// Check the fields are non-empty. Their structure will be checked
+	// by "Compile" below.
+	validateStringConfig(ctx, "like_template", r.LikeTemplate, anyRE)
+	validateStringConfig(ctx, "pattern", r.Pattern, anyRE)
+
+	_, err := rules.Compile(r)
+	if err != nil {
+		ctx.Error(err)
+	}
+}
diff --git a/analysis/internal/config/validate_test.go b/analysis/internal/config/validate_test.go
new file mode 100644
index 0000000..06e83ba
--- /dev/null
+++ b/analysis/internal/config/validate_test.go
@@ -0,0 +1,492 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+	"context"
+	"io/ioutil"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/config/validation"
+	"google.golang.org/protobuf/encoding/prototext"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+func TestServiceConfigValidator(t *testing.T) {
+	t.Parallel()
+
+	validate := func(cfg *configpb.Config) error {
+		c := validation.Context{Context: context.Background()}
+		validateConfig(&c, cfg)
+		return c.Finalize()
+	}
+
+	Convey("config template is valid", t, func() {
+		content, err := ioutil.ReadFile(
+			"../../configs/services/chops-weetbix-dev/config-template.cfg",
+		)
+		So(err, ShouldBeNil)
+		cfg := &configpb.Config{}
+		So(prototext.Unmarshal(content, cfg), ShouldBeNil)
+		So(validate(cfg), ShouldBeNil)
+	})
+
+	Convey("valid config is valid", t, func() {
+		cfg, err := CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		So(validate(cfg), ShouldBeNil)
+	})
+
+	Convey("monorail hostname", t, func() {
+		cfg, err := CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		Convey("must be specified", func() {
+			cfg.MonorailHostname = ""
+			So(validate(cfg), ShouldErrLike, "empty value is not allowed")
+		})
+		Convey("must be correctly formed", func() {
+			cfg.MonorailHostname = "monorail host"
+			So(validate(cfg), ShouldErrLike, `invalid hostname: "monorail host"`)
+		})
+	})
+	Convey("chunk GCS bucket", t, func() {
+		cfg, err := CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		Convey("must be specified", func() {
+			cfg.ChunkGcsBucket = ""
+			So(validate(cfg), ShouldErrLike, "empty chunk_gcs_bucket is not allowed")
+		})
+		Convey("must be correctly formed", func() {
+			cfg, err := CreatePlaceholderConfig()
+			So(err, ShouldBeNil)
+
+			cfg.ChunkGcsBucket = "my bucket"
+			So(validate(cfg), ShouldErrLike, `invalid chunk_gcs_bucket: "my bucket"`)
+		})
+	})
+	Convey("reclustering workers", t, func() {
+		cfg, err := CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		Convey("less than zero", func() {
+			cfg.ReclusteringWorkers = -1
+			So(validate(cfg), ShouldErrLike, `value is less than zero`)
+		})
+		Convey("too large", func() {
+			cfg.ReclusteringWorkers = 1001
+			So(validate(cfg), ShouldErrLike, `value is greater than 1000`)
+		})
+	})
+	Convey("reclustering interval", t, func() {
+		cfg, err := CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		Convey("less than zero", func() {
+			cfg.ReclusteringIntervalMinutes = -1
+			So(validate(cfg), ShouldErrLike, `value is less than zero`)
+		})
+		Convey("too large", func() {
+			cfg.ReclusteringIntervalMinutes = 10
+			So(validate(cfg), ShouldErrLike, `value is greater than 9`)
+		})
+	})
+}
+
+func TestProjectConfigValidator(t *testing.T) {
+	t.Parallel()
+
+	validate := func(cfg *configpb.ProjectConfig) error {
+		c := validation.Context{Context: context.Background()}
+		ValidateProjectConfig(&c, cfg)
+		return c.Finalize()
+	}
+
+	Convey("config template is valid", t, func() {
+		content, err := ioutil.ReadFile(
+			"../../configs/projects/chromium/chops-weetbix-dev-template.cfg",
+		)
+		So(err, ShouldBeNil)
+		cfg := &configpb.ProjectConfig{}
+		So(prototext.Unmarshal(content, cfg), ShouldBeNil)
+		So(validate(cfg), ShouldBeNil)
+	})
+
+	Convey("valid config is valid", t, func() {
+		cfg := CreatePlaceholderProjectConfig()
+		So(validate(cfg), ShouldBeNil)
+	})
+
+	Convey("monorail", t, func() {
+		cfg := CreatePlaceholderProjectConfig()
+		Convey("must be specified", func() {
+			cfg.Monorail = nil
+			So(validate(cfg), ShouldErrLike, "monorail must be specified")
+		})
+
+		Convey("project must be specified", func() {
+			cfg.Monorail.Project = ""
+			So(validate(cfg), ShouldErrLike, "empty project is not allowed")
+		})
+
+		Convey("illegal monorail project", func() {
+			// Project does not satisfy regex.
+			cfg.Monorail.Project = "-my-project"
+			So(validate(cfg), ShouldErrLike, `invalid project: "-my-project"`)
+		})
+
+		Convey("negative priority field ID", func() {
+			cfg.Monorail.PriorityFieldId = -1
+			So(validate(cfg), ShouldErrLike, "value must be non-negative")
+		})
+
+		Convey("field value with negative field ID", func() {
+			cfg.Monorail.DefaultFieldValues = []*configpb.MonorailFieldValue{
+				{
+					FieldId: -1,
+					Value:   "",
+				},
+			}
+			So(validate(cfg), ShouldErrLike, "value must be non-negative")
+		})
+
+		Convey("priorities", func() {
+			priorities := cfg.Monorail.Priorities
+			Convey("at least one must be specified", func() {
+				cfg.Monorail.Priorities = nil
+				So(validate(cfg), ShouldErrLike, "at least one monorail priority must be specified")
+			})
+
+			Convey("priority value is empty", func() {
+				priorities[0].Priority = ""
+				So(validate(cfg), ShouldErrLike, "empty value is not allowed")
+			})
+
+			Convey("threshold is not specified", func() {
+				priorities[0].Threshold = nil
+				So(validate(cfg), ShouldErrLike, "impact thresolds must be specified")
+			})
+
+			Convey("last priority thresholds must be satisfied by the bug-filing threshold", func() {
+				lastPriority := priorities[len(priorities)-1]
+				bugFilingThres := cfg.BugFilingThreshold
+
+				// Test validation applies to all metrics.
+				Convey("critical test failures exonerated", func() {
+					bugFilingThres.CriticalFailuresExonerated = &configpb.MetricThreshold{OneDay: proto.Int64(70)}
+					lastPriority.Threshold.CriticalFailuresExonerated = nil
+					So(validate(cfg), ShouldErrLike, "critical_failures_exonerated / one_day): one_day threshold must be set, with a value of at most 70")
+				})
+
+				Convey("test results failed", func() {
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+					lastPriority.Threshold.TestResultsFailed = nil
+					So(validate(cfg), ShouldErrLike, "test_results_failed / one_day): one_day threshold must be set, with a value of at most 100")
+				})
+
+				Convey("test runs failed", func() {
+					bugFilingThres.TestRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(50)}
+					lastPriority.Threshold.TestRunsFailed = nil
+					So(validate(cfg), ShouldErrLike, "test_runs_failed / one_day): one_day threshold must be set, with a value of at most 50")
+				})
+
+				Convey("presubmit runs failed", func() {
+					bugFilingThres.PresubmitRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(10)}
+					lastPriority.Threshold.PresubmitRunsFailed = nil
+					So(validate(cfg), ShouldErrLike, "presubmit_runs_failed / one_day): one_day threshold must be set, with a value of at most 10")
+				})
+
+				// The following properties should hold for all metrics. We test
+				// on one metric as the code is re-used for all metrics.
+				Convey("one day threshold", func() {
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+					lastPriority.Threshold.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(101)}
+					So(validate(cfg), ShouldErrLike, "test_results_failed / one_day): value must be at most 100")
+				})
+
+				Convey("three day threshold", func() {
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{ThreeDay: proto.Int64(300)}
+					lastPriority.Threshold.TestResultsFailed = &configpb.MetricThreshold{ThreeDay: proto.Int64(301)}
+					So(validate(cfg), ShouldErrLike, "test_results_failed / three_day): value must be at most 300")
+				})
+
+				Convey("seven day threshold", func() {
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(700)}
+					lastPriority.Threshold.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(701)}
+					So(validate(cfg), ShouldErrLike, "test_results_failed / seven_day): value must be at most 700")
+				})
+
+				Convey("one day-filing threshold implies seven-day keep open threshold", func() {
+					// Verify implications work across time.
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+					lastPriority.Threshold.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(100)}
+					So(validate(cfg), ShouldBeNil)
+				})
+
+				Convey("seven day-filing threshold does not imply one-day keep open threshold", func() {
+					// This implication does not work.
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(700)}
+					lastPriority.Threshold.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(700)}
+					So(validate(cfg), ShouldErrLike, "test_results_failed / seven_day): seven_day threshold must be set, with a value of at most 700")
+				})
+
+				Convey("metric threshold nil", func() {
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+					lastPriority.Threshold.TestResultsFailed = nil
+					So(validate(cfg), ShouldErrLike, "test_results_failed / one_day): one_day threshold must be set, with a value of at most 100")
+				})
+
+				Convey("metric threshold not set", func() {
+					bugFilingThres.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(100)}
+					lastPriority.Threshold.TestResultsFailed = &configpb.MetricThreshold{}
+					So(validate(cfg), ShouldErrLike, "test_results_failed / one_day): one_day threshold must be set, with a value of at most 100")
+				})
+			})
+			// Other thresholding validation cases tested under bug-filing threshold and are
+			// not repeated given the implementation is shared.
+		})
+
+		Convey("priority hysteresis", func() {
+			Convey("value too high", func() {
+				cfg.Monorail.PriorityHysteresisPercent = 1001
+				So(validate(cfg), ShouldErrLike, "value must not exceed 1000 percent")
+			})
+			Convey("value is negative", func() {
+				cfg.Monorail.PriorityHysteresisPercent = -1
+				So(validate(cfg), ShouldErrLike, "value must not be negative")
+			})
+		})
+
+		Convey("monorail hostname", func() {
+			// Only the domain name should be supplied, not the protocol.
+			cfg.Monorail.MonorailHostname = "http://bugs.chromium.org"
+			So(validate(cfg), ShouldErrLike, "invalid hostname")
+		})
+
+		Convey("display prefix", func() {
+			// ";" is not allowed to appear in the prefix.
+			cfg.Monorail.DisplayPrefix = "chromium:"
+			So(validate(cfg), ShouldErrLike, "invalid display prefix")
+		})
+	})
+	Convey("bug filing threshold", t, func() {
+		cfg := CreatePlaceholderProjectConfig()
+		threshold := cfg.BugFilingThreshold
+		So(threshold, ShouldNotBeNil)
+
+		Convey("must be specified", func() {
+			cfg.BugFilingThreshold = nil
+			So(validate(cfg), ShouldErrLike, "impact thresolds must be specified")
+		})
+
+		Convey("handles unset metric thresholds", func() {
+			threshold := cfg.BugFilingThreshold
+			threshold.CriticalFailuresExonerated = nil
+			threshold.TestResultsFailed = nil
+			threshold.TestRunsFailed = nil
+			threshold.PresubmitRunsFailed = nil
+			So(threshold, ShouldNotBeNil)
+		})
+
+		Convey("metric values are not negative", func() {
+			// Test by threshold period.
+			Convey("one day", func() {
+				threshold.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+
+			Convey("three day", func() {
+				threshold.TestResultsFailed = &configpb.MetricThreshold{ThreeDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+
+			Convey("seven day", func() {
+				threshold.TestResultsFailed = &configpb.MetricThreshold{SevenDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+
+			// Test by metric.
+			Convey("critical test failures exonerated", func() {
+				threshold.CriticalFailuresExonerated = &configpb.MetricThreshold{OneDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+
+			Convey("test results failed", func() {
+				threshold.TestResultsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+
+			Convey("test runs failed", func() {
+				threshold.TestRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+
+			Convey("presubmit runs failed", func() {
+				threshold.PresubmitRunsFailed = &configpb.MetricThreshold{OneDay: proto.Int64(-1)}
+				So(validate(cfg), ShouldErrLike, "value must be non-negative")
+			})
+		})
+	})
+
+	Convey("realm config", t, func() {
+		cfg := CreatePlaceholderProjectConfig()
+		So(len(cfg.Realms), ShouldEqual, 1)
+		realm := cfg.Realms[0]
+
+		Convey("realm name", func() {
+			Convey("must be specified", func() {
+				realm.Name = ""
+				So(validate(cfg), ShouldErrLike, "empty realm_name is not allowed")
+			})
+			Convey("invalid", func() {
+				realm.Name = "chromium:ci"
+				So(validate(cfg), ShouldErrLike, `invalid realm_name: "chromium:ci"`)
+			})
+			Convey("valid", func() {
+				realm.Name = "ci"
+				So(validate(cfg), ShouldBeNil)
+			})
+		})
+
+		Convey("TestVariantAnalysisConfig", func() {
+			tvCfg := realm.TestVariantAnalysis
+			So(tvCfg, ShouldNotBeNil)
+			utCfg := tvCfg.UpdateTestVariantTask
+			So(utCfg, ShouldNotBeNil)
+			Convey("UpdateTestVariantTask", func() {
+				Convey("interval", func() {
+					Convey("empty not allowed", func() {
+						utCfg.UpdateTestVariantTaskInterval = nil
+						So(validate(cfg), ShouldErrLike, `empty interval is not allowed`)
+					})
+					Convey("must be greater than 0", func() {
+						utCfg.UpdateTestVariantTaskInterval = durationpb.New(-time.Hour)
+						So(validate(cfg), ShouldErrLike, `interval is less than 0`)
+					})
+				})
+
+				Convey("duration", func() {
+					Convey("empty not allowed", func() {
+						utCfg.TestVariantStatusUpdateDuration = nil
+						So(validate(cfg), ShouldErrLike, `empty duration is not allowed`)
+					})
+					Convey("must be greater than 0", func() {
+						utCfg.TestVariantStatusUpdateDuration = durationpb.New(-time.Hour)
+						So(validate(cfg), ShouldErrLike, `duration is less than 0`)
+					})
+				})
+			})
+
+			bqExports := tvCfg.BqExports
+			So(len(bqExports), ShouldEqual, 1)
+			bqe := bqExports[0]
+			So(bqe, ShouldNotBeNil)
+			Convey("BqExport", func() {
+				table := bqe.Table
+				So(table, ShouldNotBeNil)
+				Convey("BigQueryTable", func() {
+					Convey("cloud project", func() {
+						Convey("should npt be empty", func() {
+							table.CloudProject = ""
+							So(validate(cfg), ShouldErrLike, "empty cloud_project is not allowed")
+						})
+						Convey("not end with hyphen", func() {
+							table.CloudProject = "project-"
+							So(validate(cfg), ShouldErrLike, `invalid cloud_project: "project-"`)
+						})
+						Convey("not too short", func() {
+							table.CloudProject = "p"
+							So(validate(cfg), ShouldErrLike, `invalid cloud_project: "p"`)
+						})
+						Convey("must start with letter", func() {
+							table.CloudProject = "0project"
+							So(validate(cfg), ShouldErrLike, `invalid cloud_project: "0project"`)
+						})
+					})
+
+					Convey("dataset", func() {
+						Convey("should not be empty", func() {
+							table.Dataset = ""
+							So(validate(cfg), ShouldErrLike, "empty dataset is not allowed")
+						})
+						Convey("should be valid", func() {
+							table.Dataset = "data-set"
+							So(validate(cfg), ShouldErrLike, `invalid dataset: "data-set"`)
+						})
+					})
+
+					Convey("table", func() {
+						Convey("should not be empty", func() {
+							table.Table = ""
+							So(validate(cfg), ShouldErrLike, "empty table_name is not allowed")
+						})
+						Convey("should be valid", func() {
+							table.Table = "table/name"
+							So(validate(cfg), ShouldErrLike, `invalid table_name: "table/name"`)
+						})
+					})
+				})
+			})
+		})
+	})
+
+	Convey("clustering", t, func() {
+		cfg := CreatePlaceholderProjectConfig()
+		clustering := cfg.Clustering
+
+		Convey("may not be specified", func() {
+			cfg.Clustering = nil
+			So(validate(cfg), ShouldBeNil)
+		})
+		Convey("rules must be valid", func() {
+			rule := clustering.TestNameRules[0]
+			Convey("name is not specified", func() {
+				rule.Name = ""
+				So(validate(cfg), ShouldErrLike, "empty name is not allowed")
+			})
+			Convey("name is invalid", func() {
+				rule.Name = "<script>evil()</script>"
+				So(validate(cfg), ShouldErrLike, "invalid name")
+			})
+			Convey("pattern is not specified", func() {
+				rule.Pattern = ""
+				// Make sure the like template does not refer to capture
+				// groups in the pattern, to avoid other errors in this test.
+				rule.LikeTemplate = "%blah%"
+				So(validate(cfg), ShouldErrLike, "empty pattern is not allowed")
+			})
+			Convey("pattern is invalid", func() {
+				rule.Pattern = "["
+				So(validate(cfg), ShouldErrLike, `error parsing regexp: missing closing ]`)
+			})
+			Convey("like template is not specified", func() {
+				rule.LikeTemplate = ""
+				So(validate(cfg), ShouldErrLike, "empty like_template is not allowed")
+			})
+			Convey("like template is invalid", func() {
+				rule.LikeTemplate = "blah${broken"
+				So(validate(cfg), ShouldErrLike, `invalid use of the $ operator at position 4 in "blah${broken"`)
+			})
+		})
+	})
+}
diff --git a/analysis/internal/cv/cv.go b/analysis/internal/cv/cv.go
new file mode 100644
index 0000000..f8a75dc
--- /dev/null
+++ b/analysis/internal/cv/cv.go
@@ -0,0 +1,65 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package cv contains logic of interacting with CV (LUCI Change Verifier).
+package cv
+
+import (
+	"context"
+	"net/http"
+
+	"google.golang.org/grpc"
+
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+	"go.chromium.org/luci/grpc/prpc"
+	"go.chromium.org/luci/server/auth"
+)
+
+// fakeCVClientKey is the context key indicates using fake CV client in tests.
+var fakeCVClientKey = "used in tests only for setting the fake CV client"
+
+func newRunsClient(ctx context.Context, host string) (Client, error) {
+	if fc, ok := ctx.Value(&fakeCVClientKey).(*FakeClient); ok {
+		return fc, nil
+	}
+
+	t, err := auth.GetRPCTransport(ctx, auth.AsSelf)
+	if err != nil {
+		return nil, err
+	}
+	return cvv0.NewRunsClient(
+		&prpc.Client{
+			C:       &http.Client{Transport: t},
+			Host:    host,
+			Options: prpc.DefaultOptions(),
+		}), nil
+}
+
+// Client defines a subset of CV API consumed by Weebtix.
+type Client interface {
+	GetRun(ctx context.Context, in *cvv0.GetRunRequest, opts ...grpc.CallOption) (*cvv0.Run, error)
+}
+
+// ensure Client is a subset of CV interface.
+var _ Client = (cvv0.RunsClient)(nil)
+
+// NewClient creates a client to communicate with CV.
+func NewClient(ctx context.Context, host string) (Client, error) {
+	client, err := newRunsClient(ctx, host)
+	if err != nil {
+		return nil, err
+	}
+
+	return client, nil
+}
diff --git a/analysis/internal/cv/cv_test.go b/analysis/internal/cv/cv_test.go
new file mode 100644
index 0000000..ba263c8
--- /dev/null
+++ b/analysis/internal/cv/cv_test.go
@@ -0,0 +1,43 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cv
+
+import (
+	"context"
+	"testing"
+
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestGetRun(t *testing.T) {
+	t.Parallel()
+
+	Convey("Get run", t, func() {
+		rID := "projects/chromium/runs/run-id"
+		runs := map[string]*cvv0.Run{
+			rID: {},
+		}
+		ctx := UseFakeClient(context.Background(), runs)
+		client, err := NewClient(ctx, "host")
+		So(err, ShouldBeNil)
+		req := &cvv0.GetRunRequest{
+			Id: rID,
+		}
+		_, err = client.GetRun(ctx, req)
+		So(err, ShouldBeNil)
+	})
+}
diff --git a/analysis/internal/cv/fake.go b/analysis/internal/cv/fake.go
new file mode 100644
index 0000000..455db9e
--- /dev/null
+++ b/analysis/internal/cv/fake.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cv
+
+import (
+	"context"
+	"fmt"
+
+	"google.golang.org/grpc"
+
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+)
+
+// FakeClient provides a fake implementation of cvv0.RunsClient for testing.
+type FakeClient struct {
+	Runs map[string]*cvv0.Run
+}
+
+func UseFakeClient(ctx context.Context, runs map[string]*cvv0.Run) context.Context {
+	return context.WithValue(ctx, &fakeCVClientKey, &FakeClient{Runs: runs})
+}
+
+// GetRun mocks cvv0.RunsClient.GetRun RPC.
+func (fc *FakeClient) GetRun(ctx context.Context, req *cvv0.GetRunRequest, opts ...grpc.CallOption) (*cvv0.Run, error) {
+	if r, ok := fc.Runs[req.Id]; ok {
+		return r, nil
+	}
+	return nil, fmt.Errorf("not found")
+}
diff --git a/analysis/internal/ingestion/control/doc.go b/analysis/internal/ingestion/control/doc.go
new file mode 100644
index 0000000..ae1ae16
--- /dev/null
+++ b/analysis/internal/ingestion/control/doc.go
@@ -0,0 +1,20 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package control provides methods to read and write records used to:
+// 1. Ensure exactly-once ingestion of test results from builds.
+// 2. Synchronise build completion and presubmit run completion, so that
+//    ingestion only proceeds when both build and presubmit run have
+//    completed.
+package control
diff --git a/analysis/internal/ingestion/control/main_test.go b/analysis/internal/ingestion/control/main_test.go
new file mode 100644
index 0000000..3f85eb0
--- /dev/null
+++ b/analysis/internal/ingestion/control/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package control
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/ingestion/control/proto/control.pb.go b/analysis/internal/ingestion/control/proto/control.pb.go
new file mode 100644
index 0000000..9cbefd7
--- /dev/null
+++ b/analysis/internal/ingestion/control/proto/control.pb.go
@@ -0,0 +1,354 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/ingestion/control/proto/control.proto
+
+package controlpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// BuildResult represents the result from the buildbucket pub/sub
+// that should be passed to the result ingestion task.
+type BuildResult struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Buildbucket build ID, unique per Buildbucket instance.
+	Id int64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
+	// Buildbucket host, e.g. "cr-buildbucket.appspot.com".
+	Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"`
+	// The time the build was created.
+	CreationTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=creation_time,json=creationTime,proto3" json:"creation_time,omitempty"`
+	// The LUCI Project to which the build belongs.
+	Project string `protobuf:"bytes,4,opt,name=project,proto3" json:"project,omitempty"`
+}
+
+func (x *BuildResult) Reset() {
+	*x = BuildResult{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BuildResult) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BuildResult) ProtoMessage() {}
+
+func (x *BuildResult) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BuildResult.ProtoReflect.Descriptor instead.
+func (*BuildResult) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *BuildResult) GetId() int64 {
+	if x != nil {
+		return x.Id
+	}
+	return 0
+}
+
+func (x *BuildResult) GetHost() string {
+	if x != nil {
+		return x.Host
+	}
+	return ""
+}
+
+func (x *BuildResult) GetCreationTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreationTime
+	}
+	return nil
+}
+
+func (x *BuildResult) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+// PresubmitResult represents the result from the presubmit pub/sub
+// that should be passed to the result ingestion task.
+type PresubmitResult struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The identity of the presubmit run. If the ingestion does not relate to test
+	// results obtained in a presubmit run, this field should not be set.
+	PresubmitRunId *v1.PresubmitRunId `protobuf:"bytes,1,opt,name=presubmit_run_id,json=presubmitRunId,proto3" json:"presubmit_run_id,omitempty"`
+	// The ending status of the presubmit run. E.g. Canceled, Success, Failure.
+	Status v1.PresubmitRunStatus `protobuf:"varint,9,opt,name=status,proto3,enum=weetbix.v1.PresubmitRunStatus" json:"status,omitempty"`
+	// The presubmit run mode.
+	// E.g. FULL_RUN, DRY_RUN, QUICK_DRY_RUN.
+	Mode v1.PresubmitRunMode `protobuf:"varint,8,opt,name=mode,proto3,enum=weetbix.v1.PresubmitRunMode" json:"mode,omitempty"`
+	// The owner of the presubmit run (if any).
+	// This is the owner of the CL on which CQ+1/CQ+2 was clicked
+	// (even in case of presubmit run with multiple CLs).
+	// There is scope for this field to become an email address if privacy
+	// approval is obtained, until then it is "automation" (for automation
+	// service accounts) and "user" otherwise.
+	Owner string `protobuf:"bytes,4,opt,name=owner,proto3" json:"owner,omitempty"`
+	// The time the presubmit was created.
+	CreationTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=creation_time,json=creationTime,proto3" json:"creation_time,omitempty"`
+	// Whether the build was critical to the completion of the presubmit run.
+	// True if the failure of the build would cause the presubmit run to fail.
+	Critical bool `protobuf:"varint,7,opt,name=critical,proto3" json:"critical,omitempty"`
+}
+
+func (x *PresubmitResult) Reset() {
+	*x = PresubmitResult{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PresubmitResult) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PresubmitResult) ProtoMessage() {}
+
+func (x *PresubmitResult) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use PresubmitResult.ProtoReflect.Descriptor instead.
+func (*PresubmitResult) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *PresubmitResult) GetPresubmitRunId() *v1.PresubmitRunId {
+	if x != nil {
+		return x.PresubmitRunId
+	}
+	return nil
+}
+
+func (x *PresubmitResult) GetStatus() v1.PresubmitRunStatus {
+	if x != nil {
+		return x.Status
+	}
+	return v1.PresubmitRunStatus(0)
+}
+
+func (x *PresubmitResult) GetMode() v1.PresubmitRunMode {
+	if x != nil {
+		return x.Mode
+	}
+	return v1.PresubmitRunMode(0)
+}
+
+func (x *PresubmitResult) GetOwner() string {
+	if x != nil {
+		return x.Owner
+	}
+	return ""
+}
+
+func (x *PresubmitResult) GetCreationTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreationTime
+	}
+	return nil
+}
+
+func (x *PresubmitResult) GetCritical() bool {
+	if x != nil {
+		return x.Critical
+	}
+	return false
+}
+
+var File_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDesc = []byte{
+	0x0a, 0x46, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e,
+	0x74, 0x72, 0x6f, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72,
+	0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x22, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x69, 0x6e, 0x67, 0x65, 0x73,
+	0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x1a, 0x1f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69,
+	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f,
+	0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8c, 0x01, 0x0a,
+	0x0b, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0e, 0x0a, 0x02,
+	0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04,
+	0x68, 0x6f, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74,
+	0x12, 0x3f, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d,
+	0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74,
+	0x61, 0x6d, 0x70, 0x52, 0x0c, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d,
+	0x65, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x22, 0xc6, 0x02, 0x0a, 0x0f,
+	0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12,
+	0x44, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e,
+	0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74,
+	0x52, 0x75, 0x6e, 0x49, 0x64, 0x52, 0x0e, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74,
+	0x52, 0x75, 0x6e, 0x49, 0x64, 0x12, 0x36, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18,
+	0x09, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x53,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x30, 0x0a,
+	0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d,
+	0x69, 0x74, 0x52, 0x75, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12,
+	0x14, 0x0a, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+	0x6f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x3f, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0c, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63,
+	0x61, 0x6c, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63,
+	0x61, 0x6c, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x4a, 0x04, 0x08, 0x05, 0x10, 0x06, 0x4a, 0x04,
+	0x08, 0x06, 0x10, 0x07, 0x42, 0x44, 0x5a, 0x42, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70,
+	0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f,
+	0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x69,
+	0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x3b, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescData = file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_goTypes = []interface{}{
+	(*BuildResult)(nil),           // 0: weetbix.internal.ingestion.control.BuildResult
+	(*PresubmitResult)(nil),       // 1: weetbix.internal.ingestion.control.PresubmitResult
+	(*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp
+	(*v1.PresubmitRunId)(nil),     // 3: weetbix.v1.PresubmitRunId
+	(v1.PresubmitRunStatus)(0),    // 4: weetbix.v1.PresubmitRunStatus
+	(v1.PresubmitRunMode)(0),      // 5: weetbix.v1.PresubmitRunMode
+}
+var file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_depIdxs = []int32{
+	2, // 0: weetbix.internal.ingestion.control.BuildResult.creation_time:type_name -> google.protobuf.Timestamp
+	3, // 1: weetbix.internal.ingestion.control.PresubmitResult.presubmit_run_id:type_name -> weetbix.v1.PresubmitRunId
+	4, // 2: weetbix.internal.ingestion.control.PresubmitResult.status:type_name -> weetbix.v1.PresubmitRunStatus
+	5, // 3: weetbix.internal.ingestion.control.PresubmitResult.mode:type_name -> weetbix.v1.PresubmitRunMode
+	2, // 4: weetbix.internal.ingestion.control.PresubmitResult.creation_time:type_name -> google.protobuf.Timestamp
+	5, // [5:5] is the sub-list for method output_type
+	5, // [5:5] is the sub-list for method input_type
+	5, // [5:5] is the sub-list for extension type_name
+	5, // [5:5] is the sub-list for extension extendee
+	0, // [0:5] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_init() }
+func file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_init() {
+	if File_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BuildResult); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*PresubmitResult); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto = out.File
+	file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_rawDesc = nil
+	file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_goTypes = nil
+	file_infra_appengine_weetbix_internal_ingestion_control_proto_control_proto_depIdxs = nil
+}
diff --git a/analysis/internal/ingestion/control/proto/control.proto b/analysis/internal/ingestion/control/proto/control.proto
new file mode 100644
index 0000000..5af7c85
--- /dev/null
+++ b/analysis/internal/ingestion/control/proto/control.proto
@@ -0,0 +1,72 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.internal.ingestion.control;
+
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+
+option go_package = "go.chromium.org/luci/analysis/internal/ingestion/control/proto;controlpb";
+
+// BuildResult represents the result from the buildbucket pub/sub
+// that should be passed to the result ingestion task.
+message BuildResult {
+  // Buildbucket build ID, unique per Buildbucket instance.
+  int64 id = 1;
+
+  // Buildbucket host, e.g. "cr-buildbucket.appspot.com".
+  string host = 2;
+
+  // The time the build was created.
+  google.protobuf.Timestamp creation_time = 3;
+
+  // The LUCI Project to which the build belongs.
+  string project = 4;
+}
+
+// PresubmitResult represents the result from the presubmit pub/sub
+// that should be passed to the result ingestion task.
+message PresubmitResult {
+  // The identity of the presubmit run. If the ingestion does not relate to test
+  // results obtained in a presubmit run, this field should not be set.
+  weetbix.v1.PresubmitRunId presubmit_run_id = 1;
+
+  reserved 2;
+
+  // The ending status of the presubmit run. E.g. Canceled, Success, Failure.
+  weetbix.v1.PresubmitRunStatus status = 9;
+
+  // The presubmit run mode.
+  // E.g. FULL_RUN, DRY_RUN, QUICK_DRY_RUN.
+  weetbix.v1.PresubmitRunMode mode = 8;
+
+  // The owner of the presubmit run (if any).
+  // This is the owner of the CL on which CQ+1/CQ+2 was clicked
+  // (even in case of presubmit run with multiple CLs).
+  // There is scope for this field to become an email address if privacy
+  // approval is obtained, until then it is "automation" (for automation
+  // service accounts) and "user" otherwise.
+  string owner = 4;
+
+  reserved 5, 6;
+
+  // The time the presubmit was created.
+  google.protobuf.Timestamp creation_time = 3;
+
+  // Whether the build was critical to the completion of the presubmit run.
+  // True if the failure of the build would cause the presubmit run to fail.
+  bool critical = 7;
+}
diff --git a/analysis/internal/ingestion/control/proto/gen.go b/analysis/internal/ingestion/control/proto/gen.go
new file mode 100644
index 0000000..a6ca814
--- /dev/null
+++ b/analysis/internal/ingestion/control/proto/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controlpb
+
+//go:generate cproto
diff --git a/analysis/internal/ingestion/control/span.go b/analysis/internal/ingestion/control/span.go
new file mode 100644
index 0000000..bdf720d
--- /dev/null
+++ b/analysis/internal/ingestion/control/span.go
@@ -0,0 +1,382 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package control
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	ctlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+)
+
+// JoinStatsHours is the number of previous hours
+// ReadPresubmitRunJoinStatistics/ReadBuildJoinStatistics reads statistics for.
+const JoinStatsHours = 36
+
+// Entry is an ingestion control record, used to de-duplicate build ingestions
+// and synchronise them with presubmit results (if required).
+type Entry struct {
+	// The identity of the build which is being ingested.
+	// The scheme is: {buildbucket host name}/{build id}.
+	BuildID string
+
+	// Project is the LUCI Project the build belongs to. Used for
+	// metrics monitoring build/presubmit join performance.
+	BuildProject string
+
+	// BuildResult is the result of the build bucket build, to be passed
+	// to the result ingestion task. This is nil if the result is
+	// not yet known.
+	BuildResult *ctlpb.BuildResult
+
+	// BuildJoinedTime is the Spanner commit time the build result was
+	// populated. If join has not yet occurred, this is the zero time.
+	BuildJoinedTime time.Time
+
+	// IsPresubmit records whether the build is part of a presubmit run.
+	// If true, ingestion should wait for the presubmit result to be
+	// populated (in addition to the build result) before commencing
+	// ingestion.
+	IsPresubmit bool
+
+	// PresubmitProject is the LUCI Project the presubmit run belongs to.
+	// This may differ from the LUCI Project teh build belongs to. Used for
+	// metrics monitoring build/presubmit join performance.
+	PresubmitProject string
+
+	// PresubmitResult is result of the presubmit run, to be passed to the
+	// result ingestion task. This is nil if the result is
+	// not yet known.
+	PresubmitResult *ctlpb.PresubmitResult
+
+	// PresubmitJoinedTime is the Spanner commit time the presubmit result was
+	// populated. If join has not yet occurred, this is the zero time.
+	PresubmitJoinedTime time.Time
+
+	// LastUpdated is the Spanner commit time the row was last updated.
+	LastUpdated time.Time
+
+	// The number of test result ingestion tasks have been created for this
+	// invocation.
+	// Used to avoid duplicate scheduling of ingestion tasks. If the page_index
+	// is the index of the page being processed, an ingestion task for the next
+	// page will only be created if (page_index + 1) == TaskCount.
+	TaskCount int64
+}
+
+// BuildID returns the control record key for a buildbucket build with the
+// given hostname and ID.
+func BuildID(hostname string, id int64) string {
+	return fmt.Sprintf("%s/%v", hostname, id)
+}
+
+// Read reads ingestion control records for the specified build IDs.
+// Exactly one *Entry is returned for each build ID. The result entry
+// at index i corresponds to the buildIDs[i].
+// If a record does not exist for the given build ID, an *Entry of
+// nil is returned for that build ID.
+func Read(ctx context.Context, buildIDs []string) ([]*Entry, error) {
+	uniqueIDs := make(map[string]struct{})
+	var keys []spanner.Key
+	for _, buildID := range buildIDs {
+		keys = append(keys, spanner.Key{buildID})
+		if _, ok := uniqueIDs[buildID]; ok {
+			return nil, fmt.Errorf("duplicate build ID %s", buildID)
+		}
+		uniqueIDs[buildID] = struct{}{}
+	}
+	cols := []string{
+		"BuildID",
+		"BuildProject",
+		"BuildResult",
+		"BuildJoinedTime",
+		"IsPresubmit",
+		"PresubmitProject",
+		"PresubmitResult",
+		"PresubmitJoinedTime",
+		"LastUpdated",
+		"TaskCount",
+	}
+	entryByBuildID := make(map[string]*Entry)
+	rows := span.Read(ctx, "Ingestions", spanner.KeySetFromKeys(keys...), cols)
+	f := func(r *spanner.Row) error {
+		var buildID string
+		var buildProject, presubmitProject spanner.NullString
+		var buildResultBytes []byte
+		var isPresubmit spanner.NullBool
+		var presubmitResultBytes []byte
+		var buildJoinedTime, presubmitJoinedTime spanner.NullTime
+		var lastUpdated time.Time
+		var taskCount spanner.NullInt64
+
+		err := r.Columns(
+			&buildID,
+			&buildProject,
+			&buildResultBytes,
+			&buildJoinedTime,
+			&isPresubmit,
+			&presubmitProject,
+			&presubmitResultBytes,
+			&presubmitJoinedTime,
+			&lastUpdated,
+			&taskCount)
+		if err != nil {
+			return errors.Annotate(err, "read Ingestions row").Err()
+		}
+		var buildResult *ctlpb.BuildResult
+		if buildResultBytes != nil {
+			buildResult = &ctlpb.BuildResult{}
+			if err := proto.Unmarshal(buildResultBytes, buildResult); err != nil {
+				return errors.Annotate(err, "unmarshal build result").Err()
+			}
+		}
+		var presubmitResult *ctlpb.PresubmitResult
+		if presubmitResultBytes != nil {
+			presubmitResult = &ctlpb.PresubmitResult{}
+			if err := proto.Unmarshal(presubmitResultBytes, presubmitResult); err != nil {
+				return errors.Annotate(err, "unmarshal presubmit result").Err()
+			}
+		}
+
+		entryByBuildID[buildID] = &Entry{
+			BuildID:         buildID,
+			BuildProject:    buildProject.StringVal,
+			BuildResult:     buildResult,
+			BuildJoinedTime: buildJoinedTime.Time,
+			// IsPresubmit uses NULL to indicate false.
+			IsPresubmit:         isPresubmit.Valid && isPresubmit.Bool,
+			PresubmitProject:    presubmitProject.StringVal,
+			PresubmitResult:     presubmitResult,
+			PresubmitJoinedTime: presubmitJoinedTime.Time,
+			LastUpdated:         lastUpdated,
+			TaskCount:           taskCount.Int64,
+		}
+		return nil
+	}
+
+	if err := rows.Do(f); err != nil {
+		return nil, err
+	}
+
+	var result []*Entry
+	for _, buildID := range buildIDs {
+		// If the entry does not exist, return nil for that build ID.
+		entry := entryByBuildID[buildID]
+		result = append(result, entry)
+	}
+	return result, nil
+}
+
+// InsertOrUpdate creates or updates the given ingestion record.
+// This operation is not safe to perform blindly; perform only in a
+// read/write transaction with an attempted read of the corresponding entry.
+func InsertOrUpdate(ctx context.Context, e *Entry) error {
+	if err := validateEntry(e); err != nil {
+		return err
+	}
+	update := map[string]interface{}{
+		"BuildId":             e.BuildID,
+		"IsPresubmit":         spanner.NullBool{Valid: e.IsPresubmit, Bool: e.IsPresubmit},
+		"BuildProject":        spanner.NullString{Valid: e.BuildProject != "", StringVal: e.BuildProject},
+		"BuildResult":         e.BuildResult,
+		"BuildJoinedTime":     spanner.NullTime{Valid: e.BuildJoinedTime != time.Time{}, Time: e.BuildJoinedTime},
+		"PresubmitProject":    spanner.NullString{Valid: e.PresubmitProject != "", StringVal: e.PresubmitProject},
+		"PresubmitResult":     e.PresubmitResult,
+		"PresubmitJoinedTime": spanner.NullTime{Valid: e.PresubmitJoinedTime != time.Time{}, Time: e.PresubmitJoinedTime},
+		"LastUpdated":         spanner.CommitTimestamp,
+		"TaskCount":           e.TaskCount,
+	}
+	m := spanutil.InsertOrUpdateMap("Ingestions", update)
+	span.BufferWrite(ctx, m)
+	return nil
+}
+
+// JoinStatistics captures indicators of how well buildbucket build
+// completions are being joined to presubmit run completions.
+type JoinStatistics struct {
+	// TotalByHour captures the number of presubmit builds in the ingestions
+	// table eligible to be joined.
+	//
+	// Data is broken down by by hours since the presubmit build became
+	// eligible for joining. Index 0 indicates the period
+	// from ]-1 hour, now], index 1 indicates [-2 hour, -1 hour] and so on.
+	TotalByHour []int64
+
+	// JoinedByHour captures the number of presubmit builds in the ingestions
+	// table eligible to be joined, which were successfully joined (have
+	// both presubmit run and buildbucket build completion present).
+	//
+	// Data is broken down by by hours since the presubmit build became
+	// eligible for joining. Index 0 indicates the period
+	// from ]-1 hour, now], index 1 indicates [-2 hour, -1 hour] and so on.
+	JoinedByHour []int64
+}
+
+// ReadPresubmitJoinStatistics measures the performance joining presubmit runs.
+//
+// The statistics returned uses presubmit builds with a buildbucket
+// build result received as the denominator for measuring join performance.
+// The performance joining to presubmit run results is then measured.
+// Data is broken down by the project of the buildbucket build.
+// The last 36 hours of data for each project is returned. Hours are
+// measured since the buildbucket build result was received.
+func ReadPresubmitRunJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) {
+	stmt := spanner.NewStatement(`
+		SELECT
+		  BuildProject as project,
+		  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), BuildJoinedTime, HOUR) as hour,
+		  COUNT(*) as total,
+		  COUNTIF(HasPresubmitResult) as joined,
+		FROM Ingestions@{FORCE_INDEX=IngestionsByIsPresubmit, spanner_emulator.disable_query_null_filtered_index_check=true}
+		WHERE IsPresubmit
+		  AND BuildJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR)
+		GROUP BY project, hour
+	`)
+	stmt.Params["hours"] = JoinStatsHours
+	return readJoinStatistics(ctx, stmt)
+}
+
+// ReadPresubmitJoinStatistics reads indicators of how well buildbucket build
+// completions are being joined to presubmit run completions.
+//
+// The statistics returned uses builds with a presubmit run
+// received as the denominator for measuring join performance.
+// The performance joining to buildbucket build results is then measured.
+// Data is broken down by the project of the presubmit run.
+// The last 36 hours of data for each project is returned. Hours are
+// measured since the presubmit run result was received.
+func ReadBuildJoinStatistics(ctx context.Context) (map[string]JoinStatistics, error) {
+	stmt := spanner.NewStatement(`
+		SELECT
+		  PresubmitProject as project,
+		  TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), PresubmitJoinedTime, HOUR) as hour,
+		  COUNT(*) as total,
+		  COUNTIF(HasBuildResult) as joined,
+		FROM Ingestions@{FORCE_INDEX=IngestionsByIsPresubmit, spanner_emulator.disable_query_null_filtered_index_check=true}
+		WHERE IsPresubmit
+		  AND PresubmitJoinedTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @hours HOUR)
+		GROUP BY project, hour
+	`)
+	stmt.Params["hours"] = JoinStatsHours
+	return readJoinStatistics(ctx, stmt)
+}
+
+func readJoinStatistics(ctx context.Context, stmt spanner.Statement) (map[string]JoinStatistics, error) {
+	result := make(map[string]JoinStatistics)
+	it := span.Query(ctx, stmt)
+	err := it.Do(func(r *spanner.Row) error {
+		var project string
+		var hour int64
+		var total, joined int64
+
+		err := r.Columns(&project, &hour, &total, &joined)
+		if err != nil {
+			return errors.Annotate(err, "read row").Err()
+		}
+
+		stats, ok := result[project]
+		if !ok {
+			stats = JoinStatistics{
+				// Add zero data for all hours.
+				TotalByHour:  make([]int64, JoinStatsHours),
+				JoinedByHour: make([]int64, JoinStatsHours),
+			}
+		}
+		stats.TotalByHour[hour] = total
+		stats.JoinedByHour[hour] = joined
+
+		result[project] = stats
+		return nil
+	})
+	if err != nil {
+		return nil, errors.Annotate(err, "query presubmit join stats by project").Err()
+	}
+	return result, nil
+}
+
+func validateEntry(e *Entry) error {
+	if e.BuildID == "" {
+		return errors.New("build ID must be specified")
+	}
+	if e.BuildResult != nil {
+		if err := validateBuildResult(e.BuildResult); err != nil {
+			return errors.Annotate(err, "build result").Err()
+		}
+		if !config.ProjectRe.MatchString(e.BuildProject) {
+			return errors.New("build project must be valid")
+		}
+	} else {
+		if e.BuildProject != "" {
+			return errors.New("build project must only be specified" +
+				" if build result is specified")
+		}
+	}
+
+	if e.PresubmitResult != nil {
+		if !e.IsPresubmit {
+			return errors.New("presubmit result must not be set unless IsPresubmit is set")
+		}
+		if err := validatePresubmitResult(e.PresubmitResult); err != nil {
+			return errors.Annotate(err, "presubmit result").Err()
+		}
+		if !config.ProjectRe.MatchString(e.PresubmitProject) {
+			return errors.New("presubmit project must be valid")
+		}
+	} else {
+		if e.PresubmitProject != "" {
+			return errors.New("presubmit project must only be specified" +
+				" if presubmit result is specified")
+		}
+	}
+	if e.TaskCount < 0 {
+		return errors.New("task count must be non-negative")
+	}
+	return nil
+}
+
+func validateBuildResult(r *ctlpb.BuildResult) error {
+	switch {
+	case r.Host == "":
+		return errors.New("host must be specified")
+	case r.Id == 0:
+		return errors.New("id must be specified")
+	case !r.CreationTime.IsValid():
+		return errors.New("creation time must be specified")
+	}
+	return nil
+}
+
+func validatePresubmitResult(r *ctlpb.PresubmitResult) error {
+	switch {
+	case r.PresubmitRunId == nil:
+		return errors.New("presubmit run ID must be specified")
+	case r.PresubmitRunId.System != "luci-cv":
+		// LUCI CV is currently the only supported system.
+		return errors.New("presubmit run system must be 'luci-cv'")
+	case r.PresubmitRunId.Id == "":
+		return errors.New("presubmit run system-specific ID must be specified")
+	case !r.CreationTime.IsValid():
+		return errors.New("creation time must be specified and valid")
+	}
+	return nil
+}
diff --git a/analysis/internal/ingestion/control/span_test.go b/analysis/internal/ingestion/control/span_test.go
new file mode 100644
index 0000000..720df25
--- /dev/null
+++ b/analysis/internal/ingestion/control/span_test.go
@@ -0,0 +1,338 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package control
+
+import (
+	"context"
+	"fmt"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestSpan(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		Convey(`Read`, func() {
+			entriesToCreate := []*Entry{
+				NewEntry(0).WithBuildID("buildbucket-instance/1").Build(),
+				NewEntry(2).WithBuildID("buildbucket-instance/2").WithBuildResult(nil).Build(),
+				NewEntry(3).WithBuildID("buildbucket-instance/3").WithPresubmitResult(nil).Build(),
+			}
+			_, err := SetEntriesForTesting(ctx, entriesToCreate...)
+			So(err, ShouldBeNil)
+
+			Convey(`None exist`, func() {
+				buildIDs := []string{"buildbucket-instance/4"}
+				results, err := Read(span.Single(ctx), buildIDs)
+				So(err, ShouldBeNil)
+				So(len(results), ShouldEqual, 1)
+				So(results[0], ShouldResembleEntry, nil)
+			})
+			Convey(`Some exist`, func() {
+				buildIDs := []string{
+					"buildbucket-instance/3",
+					"buildbucket-instance/4",
+					"buildbucket-instance/2",
+					"buildbucket-instance/1",
+				}
+				results, err := Read(span.Single(ctx), buildIDs)
+				So(err, ShouldBeNil)
+				So(len(results), ShouldEqual, 4)
+				So(results[0], ShouldResembleEntry, entriesToCreate[2])
+				So(results[1], ShouldResembleEntry, nil)
+				So(results[2], ShouldResembleEntry, entriesToCreate[1])
+				So(results[3], ShouldResembleEntry, entriesToCreate[0])
+			})
+		})
+		Convey(`InsertOrUpdate`, func() {
+			testInsertOrUpdate := func(e *Entry) (time.Time, error) {
+				commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					return InsertOrUpdate(ctx, e)
+				})
+				return commitTime.In(time.UTC), err
+			}
+
+			entryToCreate := NewEntry(0).Build()
+
+			_, err := SetEntriesForTesting(ctx, entryToCreate)
+			So(err, ShouldBeNil)
+
+			e := NewEntry(1).Build()
+
+			Convey(`Valid`, func() {
+				Convey(`Insert`, func() {
+					commitTime, err := testInsertOrUpdate(e)
+					So(err, ShouldBeNil)
+					e.LastUpdated = commitTime
+
+					result, err := Read(span.Single(ctx), []string{e.BuildID})
+					So(err, ShouldBeNil)
+					So(len(result), ShouldEqual, 1)
+					So(result[0], ShouldResembleEntry, e)
+				})
+				Convey(`Update`, func() {
+					// Update the existing entry.
+					e.BuildID = entryToCreate.BuildID
+
+					commitTime, err := testInsertOrUpdate(e)
+					So(err, ShouldBeNil)
+					e.LastUpdated = commitTime
+
+					result, err := Read(span.Single(ctx), []string{e.BuildID})
+					So(err, ShouldBeNil)
+					So(len(result), ShouldEqual, 1)
+					So(result[0], ShouldResembleEntry, e)
+				})
+			})
+			Convey(`With invalid Build Project`, func() {
+				Convey(`Missing`, func() {
+					e.BuildProject = ""
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "build project must be valid")
+				})
+				Convey(`Invalid`, func() {
+					e.BuildProject = "!"
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "build project must be valid")
+				})
+			})
+			Convey(`With missing Build ID`, func() {
+				e.BuildID = ""
+				_, err := testInsertOrUpdate(e)
+				So(err, ShouldErrLike, "build ID must be specified")
+			})
+			Convey(`With invalid Build Result`, func() {
+				Convey(`Missing host`, func() {
+					e.BuildResult.Host = ""
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "host must be specified")
+				})
+				Convey(`Missing id`, func() {
+					e.BuildResult.Id = 0
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "id must be specified")
+				})
+				Convey(`Missing creation time`, func() {
+					e.BuildResult.CreationTime = nil
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "build result: creation time must be specified")
+				})
+			})
+			Convey(`With invalid Presubmit Project`, func() {
+				Convey(`Missing`, func() {
+					e.PresubmitProject = ""
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "presubmit project must be valid")
+				})
+				Convey(`Invalid`, func() {
+					e.PresubmitProject = "!"
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "presubmit project must be valid")
+				})
+			})
+			Convey(`Missing Presubmit run ID`, func() {
+				e.PresubmitResult.PresubmitRunId = nil
+				_, err := testInsertOrUpdate(e)
+				So(err, ShouldErrLike, "presubmit run ID must be specified")
+			})
+			Convey(`With invalid Presubmit Result`, func() {
+				e = NewEntry(100).Build()
+				Convey(`Missing Presubmit run ID`, func() {
+					e.PresubmitResult.PresubmitRunId = nil
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "presubmit run ID must be specified")
+				})
+				Convey(`Invalid Presubmit run ID host`, func() {
+					e.PresubmitResult.PresubmitRunId.System = "!"
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "presubmit run system must be 'luci-cv'")
+				})
+				Convey(`Missing Presubmit run ID system-specific ID`, func() {
+					e.PresubmitResult.PresubmitRunId.Id = ""
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "presubmit run system-specific ID must be specified")
+				})
+				Convey(`Missing creation time`, func() {
+					e.PresubmitResult.CreationTime = nil
+					_, err := testInsertOrUpdate(e)
+					So(err, ShouldErrLike, "presubmit result: creation time must be specified")
+				})
+			})
+		})
+		Convey(`ReadPresubmitRunJoinStatistics`, func() {
+			Convey(`No data`, func() {
+				_, err := SetEntriesForTesting(ctx, nil...)
+				So(err, ShouldBeNil)
+
+				results, err := ReadPresubmitRunJoinStatistics(span.Single(ctx))
+				So(err, ShouldBeNil)
+				So(results, ShouldResemble, map[string]JoinStatistics{})
+			})
+			Convey(`Data`, func() {
+				reference := time.Now().Add(-1 * time.Minute)
+				entriesToCreate := []*Entry{
+					// Setup following data:
+					// Project Alpha ("alpha") :=
+					//  ]-1 hour, now]: 4 presubmit builds, 2 of which without
+					//                  presubmit result, 1 of which without
+					//                  build result.
+					//                  1 non-presubmit build.
+					//  ]-36 hours, -35 hours]: 1 presubmit build,
+					//                          with all results.
+					//  ]-37 hours, -36 hours]: 1 presubmit build,
+					//                          with all results
+					//                         (should be ignored).
+					// Project Beta ("beta") :=
+					//  ]-37 hours, -36 hours]: 1 presubmit build,
+					//                          without presubmit result.
+					NewEntry(0).WithBuildProject("alpha").WithBuildJoinedTime(reference).Build(),
+					NewEntry(1).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithPresubmitResult(nil).Build(),
+					NewEntry(2).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithPresubmitResult(nil).Build(),
+					NewEntry(3).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithBuildResult(nil).Build(),
+					NewEntry(4).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithIsPresubmit(false).WithPresubmitResult(nil).Build(),
+					NewEntry(5).WithBuildProject("alpha").WithBuildJoinedTime(reference.Add(-35 * time.Hour)).Build(),
+					NewEntry(6).WithBuildProject("alpha").WithBuildJoinedTime(reference.Add(-36 * time.Hour)).Build(),
+					NewEntry(7).WithBuildProject("beta").WithBuildJoinedTime(reference.Add(-36 * time.Hour)).WithPresubmitResult(nil).Build(),
+				}
+				_, err := SetEntriesForTesting(ctx, entriesToCreate...)
+				So(err, ShouldBeNil)
+
+				results, err := ReadPresubmitRunJoinStatistics(span.Single(ctx))
+				So(err, ShouldBeNil)
+
+				expectedAlpha := JoinStatistics{
+					TotalByHour:  make([]int64, 36),
+					JoinedByHour: make([]int64, 36),
+				}
+				expectedAlpha.TotalByHour[0] = 3
+				expectedAlpha.JoinedByHour[0] = 1
+				expectedAlpha.TotalByHour[35] = 1
+				expectedAlpha.JoinedByHour[35] = 1
+				// Only data in the last 36 hours is included, so the build
+				// older than 36 hours is excluded.
+
+				// Expect no entry to be returned for Project beta
+				// as all data is older than 36 hours.
+
+				So(results, ShouldResemble, map[string]JoinStatistics{
+					"alpha": expectedAlpha,
+				})
+			})
+		})
+		Convey(`ReadBuildJoinStatistics`, func() {
+			Convey(`No data`, func() {
+				_, err := SetEntriesForTesting(ctx, nil...)
+				So(err, ShouldBeNil)
+
+				results, err := ReadBuildJoinStatistics(span.Single(ctx))
+				So(err, ShouldBeNil)
+				So(results, ShouldResemble, map[string]JoinStatistics{})
+			})
+			Convey(`Data`, func() {
+				reference := time.Now().Add(-1 * time.Minute)
+				entriesToCreate := []*Entry{
+					// Setup following data:
+					// Project Alpha ("alpha") :=
+					//  ]-1 hour, now]: 4 presubmit builds, 2 of which without
+					//                  build result, 1 of which without
+					//                  presubmit result.
+					//                  1 non-presubmit build.
+					//  ]-36 hours, -35 hours]: 1 presubmit build,
+					//                          with all results.
+					//  ]-37 hours, -36 hours]: 1 presubmit build,
+					//                          with all results
+					//                          (should be ignored).
+					// Project Beta ("beta") :=
+					//  ]-37 hours, -36 hours]: 1 presubmit build,
+					//                          without build result.
+					NewEntry(0).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).Build(),
+					NewEntry(1).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithBuildResult(nil).Build(),
+					NewEntry(2).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithBuildResult(nil).Build(),
+					NewEntry(3).WithBuildProject("alpha").WithBuildJoinedTime(reference).WithPresubmitResult(nil).Build(),
+					NewEntry(4).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference).WithIsPresubmit(false).WithBuildResult(nil).Build(),
+					NewEntry(5).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference.Add(-35 * time.Hour)).Build(),
+					NewEntry(6).WithPresubmitProject("alpha").WithPresubmitJoinedTime(reference.Add(-36 * time.Hour)).Build(),
+					NewEntry(7).WithPresubmitProject("beta").WithPresubmitJoinedTime(reference.Add(-36 * time.Hour)).WithBuildResult(nil).Build(),
+				}
+				_, err := SetEntriesForTesting(ctx, entriesToCreate...)
+				So(err, ShouldBeNil)
+
+				results, err := ReadBuildJoinStatistics(span.Single(ctx))
+				So(err, ShouldBeNil)
+
+				expectedAlpha := JoinStatistics{
+					TotalByHour:  make([]int64, 36),
+					JoinedByHour: make([]int64, 36),
+				}
+				expectedAlpha.TotalByHour[0] = 3
+				expectedAlpha.JoinedByHour[0] = 1
+				expectedAlpha.TotalByHour[35] = 1
+				expectedAlpha.JoinedByHour[35] = 1
+				// Only data in the last 36 hours is included, so the build
+				// older than 36 hours is excluded.
+
+				// Expect no entry to be returned for Project beta
+				// as all data is older than 36 hours.
+
+				So(results, ShouldResemble, map[string]JoinStatistics{
+					"alpha": expectedAlpha,
+				})
+			})
+		})
+	})
+}
+
+func ShouldResembleEntry(actual interface{}, expected ...interface{}) string {
+	if len(expected) != 1 {
+		return fmt.Sprintf("ShouldResembleEntry expects 1 value, got %d", len(expected))
+	}
+	exp := expected[0]
+	if exp == nil {
+		return ShouldBeNil(actual)
+	}
+
+	a, ok := actual.(*Entry)
+	if !ok {
+		return "actual should be of type *Entry"
+	}
+	e, ok := exp.(*Entry)
+	if !ok {
+		return "expected value should be of type *Entry"
+	}
+
+	// Check equality of non-proto fields.
+	a.BuildResult = nil
+	a.PresubmitResult = nil
+	e.BuildResult = nil
+	e.PresubmitResult = nil
+	if msg := ShouldResemble(a, e); msg != "" {
+		return msg
+	}
+
+	// Check equality of proto fields.
+	if msg := ShouldResembleProto(a.BuildResult, e.BuildResult); msg != "" {
+		return msg
+	}
+	if msg := ShouldResembleProto(a.PresubmitResult, e.PresubmitResult); msg != "" {
+		return msg
+	}
+	return ""
+}
diff --git a/analysis/internal/ingestion/control/testutils.go b/analysis/internal/ingestion/control/testutils.go
new file mode 100644
index 0000000..f2317a0
--- /dev/null
+++ b/analysis/internal/ingestion/control/testutils.go
@@ -0,0 +1,150 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package control
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	controlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// EntryBuilder provides methods to build ingestion control records.
+type EntryBuilder struct {
+	record *Entry
+}
+
+// NewEntry starts building a new Entry.
+func NewEntry(uniqifier int) *EntryBuilder {
+	return &EntryBuilder{
+		record: &Entry{
+			BuildID:      fmt.Sprintf("buildbucket-host/%v", uniqifier),
+			BuildProject: "build-project",
+			BuildResult: &controlpb.BuildResult{
+				Host:         "buildbucket-host",
+				Id:           int64(uniqifier),
+				CreationTime: timestamppb.New(time.Date(2025, time.December, 1, 1, 2, 3, uniqifier*1000, time.UTC)),
+			},
+			BuildJoinedTime:  time.Date(2020, time.December, 11, 1, 1, 1, uniqifier*1000, time.UTC),
+			IsPresubmit:      true,
+			PresubmitProject: "presubmit-project",
+			PresubmitResult: &controlpb.PresubmitResult{
+				PresubmitRunId: &pb.PresubmitRunId{
+					System: "luci-cv",
+					Id:     fmt.Sprintf("%s/123123-%v", "presubmit-project", uniqifier),
+				},
+				Status:       pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED,
+				Mode:         pb.PresubmitRunMode_QUICK_DRY_RUN,
+				Owner:        "automation",
+				CreationTime: timestamppb.New(time.Date(2026, time.December, 1, 1, 2, 3, uniqifier*1000, time.UTC)),
+			},
+			PresubmitJoinedTime: time.Date(2020, time.December, 12, 1, 1, 1, uniqifier*1000, time.UTC),
+			LastUpdated:         time.Date(2020, time.December, 13, 1, 1, 1, uniqifier*1000, time.UTC),
+			TaskCount:           int64(uniqifier),
+		},
+	}
+}
+
+// WithBuildID specifies the build ID to use on the ingestion control record.
+func (b *EntryBuilder) WithBuildID(id string) *EntryBuilder {
+	b.record.BuildID = id
+	return b
+}
+
+// WithIsPresubmit specifies whether the ingestion relates to a presubmit run.
+func (b *EntryBuilder) WithIsPresubmit(isPresubmit bool) *EntryBuilder {
+	b.record.IsPresubmit = isPresubmit
+	return b
+}
+
+// WithBuildProject specifies the build project to use on the ingestion control record.
+func (b *EntryBuilder) WithBuildProject(project string) *EntryBuilder {
+	b.record.BuildProject = project
+	return b
+}
+
+// WithBuildResult specifies the build result for the entry.
+func (b *EntryBuilder) WithBuildResult(value *controlpb.BuildResult) *EntryBuilder {
+	b.record.BuildResult = value
+	return b
+}
+
+// WithBuildJoinedTime specifies the time the build result was populated.
+func (b *EntryBuilder) WithBuildJoinedTime(value time.Time) *EntryBuilder {
+	b.record.BuildJoinedTime = value
+	return b
+}
+
+// WithPresubmitProject specifies the presubmit project to use on the ingestion control record.
+func (b *EntryBuilder) WithPresubmitProject(project string) *EntryBuilder {
+	b.record.PresubmitProject = project
+	return b
+}
+
+// WithPresubmitResult specifies the build result for the entry.
+func (b *EntryBuilder) WithPresubmitResult(value *controlpb.PresubmitResult) *EntryBuilder {
+	b.record.PresubmitResult = value
+	return b
+}
+
+// WithPresubmitJoinedTime specifies the time the presubmit result was populated.
+func (b *EntryBuilder) WithPresubmitJoinedTime(lastUpdated time.Time) *EntryBuilder {
+	b.record.PresubmitJoinedTime = lastUpdated
+	return b
+}
+
+func (b *EntryBuilder) WithTaskCount(taskCount int64) *EntryBuilder {
+	b.record.TaskCount = taskCount
+	return b
+}
+
+// Build constructs the entry.
+func (b *EntryBuilder) Build() *Entry {
+	return b.record
+}
+
+// SetEntriesForTesting replaces the set of stored entries to match the given set.
+func SetEntriesForTesting(ctx context.Context, es ...*Entry) (time.Time, error) {
+	testutil.MustApply(ctx,
+		spanner.Delete("Ingestions", spanner.AllKeys()))
+	// Insert some Ingestion records.
+	commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		for _, r := range es {
+			ms := spanutil.InsertMap("Ingestions", map[string]interface{}{
+				"BuildId":             r.BuildID,
+				"BuildProject":        r.BuildProject,
+				"BuildResult":         r.BuildResult,
+				"BuildJoinedTime":     r.BuildJoinedTime,
+				"IsPresubmit":         r.IsPresubmit,
+				"PresubmitProject":    r.PresubmitProject,
+				"PresubmitResult":     r.PresubmitResult,
+				"PresubmitJoinedTime": r.PresubmitJoinedTime,
+				"LastUpdated":         r.LastUpdated,
+				"TaskCount":           r.TaskCount,
+			})
+			span.BufferWrite(ctx, ms)
+		}
+		return nil
+	})
+	return commitTime.In(time.UTC), err
+}
diff --git a/analysis/internal/ingestion/resultdb/conversion.go b/analysis/internal/ingestion/resultdb/conversion.go
new file mode 100644
index 0000000..1c7183a
--- /dev/null
+++ b/analysis/internal/ingestion/resultdb/conversion.go
@@ -0,0 +1,108 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultdb
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+)
+
+// GroupAndOrderTestResults groups test results into test runs, and orders
+// them by start time. Test results are returned in sorted start time order
+// within the runs, and runs are ordered based on the start time of the first
+// test result that is inside them.
+// The result order is guaranteed to be deterministic even if all test
+// results have the same start time.
+func GroupAndOrderTestResults(input []*rdbpb.TestResultBundle) [][]*rdbpb.TestResultBundle {
+	var result [][]*rdbpb.TestResultBundle
+	runIndexByName := make(map[string]int)
+
+	// Process results in order of StartTime.
+	// This is to ensure test result indexes are later
+	// assigned correctly w.r.t the actual execution order.
+	input = sortResultsByStartTime(input)
+
+	// Process test results, creating runs as they are needed.
+	// Runs will be created in the order of the first test result
+	// that is inside them.
+	for _, tr := range input {
+		testRun, err := InvocationFromTestResultName(tr.Result.Name)
+		if err != nil {
+			// This should never happen, as the test results came from
+			// ResultDB.
+			panic(err)
+		}
+		idx, ok := runIndexByName[testRun]
+		if !ok {
+			// Create an empty run.
+			idx = len(result)
+			runIndexByName[testRun] = idx
+			result = append(result, nil)
+		}
+
+		result[idx] = append(result[idx], tr)
+	}
+	return result
+}
+
+// InvocationFromTestResultName extracts the invocation that the
+// test result is immediately included inside.
+func InvocationFromTestResultName(name string) (string, error) {
+	// Using a regexp here was consuming 5% of all CPU cycles
+	// related to test verdict ingestion, so do the extracting
+	// manually using indexes.
+	// The format of the name is
+	// ^invocations/([^/]+)/tests/[^/]+/results/[^/]+$,
+	// and we want to extract the invocation name.
+	startIdx := strings.Index(name, "/")
+	if startIdx < 0 || (startIdx+1) >= len(name) || name[:startIdx] != "invocations" {
+		// This should never happen as the invocation came from ResultDB.
+		return "", fmt.Errorf("invalid test result name %q, expected invocations/{invocation_name}/...", name)
+	}
+	endIdx := strings.Index(name[startIdx+1:], "/")
+	if endIdx <= 0 {
+		// This should never happen as the invocation came from ResultDB.
+		return "", fmt.Errorf("invalid test result name %q, expected invocations/{invocation_name}/...", name)
+	}
+	endIdx = endIdx + (startIdx + 1)
+	return name[startIdx+1 : endIdx], nil
+}
+
+func sortResultsByStartTime(results []*rdbpb.TestResultBundle) []*rdbpb.TestResultBundle {
+	// Copy the results to avoid modifying parameter slice, which
+	// the caller to IngestFromResultDB may not expect.
+	sortedResults := make([]*rdbpb.TestResultBundle, len(results))
+	for i, r := range results {
+		sortedResults[i] = r
+	}
+
+	sort.Slice(sortedResults, func(i, j int) bool {
+		aResult := sortedResults[i].Result
+		bResult := sortedResults[j].Result
+		aTime := aResult.StartTime.AsTime()
+		bTime := bResult.StartTime.AsTime()
+		if aTime.Equal(bTime) {
+			// If start time the same, order by Result Name.
+			// Needed to ensure the output of this sort is
+			// deterministic given the input.
+			return aResult.Name < bResult.Name
+		}
+		return aTime.Before(bTime)
+	})
+	return sortedResults
+}
diff --git a/analysis/internal/ingestion/resultdb/conversion_test.go b/analysis/internal/ingestion/resultdb/conversion_test.go
new file mode 100644
index 0000000..9e67e5f
--- /dev/null
+++ b/analysis/internal/ingestion/resultdb/conversion_test.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultdb
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestInvocationFromTestResultName(t *testing.T) {
+	Convey("Valid input", t, func() {
+		result, err := InvocationFromTestResultName("invocations/build-1234/tests/a/results/b")
+		So(err, ShouldBeNil)
+		So(result, ShouldEqual, "build-1234")
+	})
+	Convey("Invalid input", t, func() {
+		_, err := InvocationFromTestResultName("")
+		So(err, ShouldErrLike, "invalid test result name")
+
+		_, err = InvocationFromTestResultName("projects/chromium/resource/b")
+		So(err, ShouldErrLike, "invalid test result name")
+
+		_, err = InvocationFromTestResultName("invocations/build-1234")
+		So(err, ShouldErrLike, "invalid test result name")
+
+		_, err = InvocationFromTestResultName("invocations//")
+		So(err, ShouldErrLike, "invalid test result name")
+
+		_, err = InvocationFromTestResultName("invocations/")
+		So(err, ShouldErrLike, "invalid test result name")
+
+		_, err = InvocationFromTestResultName("invocations")
+		So(err, ShouldErrLike, "invalid test result name")
+	})
+}
diff --git a/analysis/internal/metrics/cron.go b/analysis/internal/metrics/cron.go
new file mode 100644
index 0000000..8c7c0df
--- /dev/null
+++ b/analysis/internal/metrics/cron.go
@@ -0,0 +1,148 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package metrics
+
+import (
+	"context"
+	"fmt"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/tsmon"
+	"go.chromium.org/luci/common/tsmon/distribution"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	"go.chromium.org/luci/common/tsmon/types"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+)
+
+var (
+	activeRulesGauge = metric.NewInt(
+		"weetbix/clustering/active_rules",
+		"The total number of active rules, by LUCI project.",
+		&types.MetricMetadata{Units: "rules"},
+		// The LUCI Project.
+		field.String("project"))
+
+	joinToBuildGauge = metric.NewNonCumulativeDistribution(
+		"weetbix/ingestion/join/to_build_result_by_hour",
+		fmt.Sprintf(
+			"The age distribution of presubmit builds with a presubmit"+
+				" result recorded, broken down by project of the presubmit "+
+				" run and whether the builds are joined to a buildbucket "+
+				" build result."+
+				" Age is measured as hours since the presubmit run result was"+
+				" recorded. Only recent data (age < %v hours) is included."+
+				" Used to measure Weetbix's performance joining to"+
+				" buildbucket builds.", control.JoinStatsHours),
+		&types.MetricMetadata{Units: "hours ago"},
+		distribution.FixedWidthBucketer(1, control.JoinStatsHours),
+		// The LUCI Project.
+		field.String("project"),
+		field.Bool("joined"))
+
+	joinToPresubmitGauge = metric.NewNonCumulativeDistribution(
+		"weetbix/ingestion/join/to_presubmit_result_by_hour",
+		fmt.Sprintf(
+			"The age distribution of presubmit builds with a buildbucket"+
+				" build result recorded, broken down by project of the"+
+				" buildbucket build and whether the builds are joined to"+
+				" a presubmit run result."+
+				" Age is measured as hours since the buildbucket build"+
+				" result was recorded. Only recent data (age < %v hours)"+
+				" is included."+
+				" Used to measure Weetbix's performance joining to presubmit"+
+				" runs.", control.JoinStatsHours),
+		&types.MetricMetadata{Units: "hours ago"},
+		distribution.FixedWidthBucketer(1, control.JoinStatsHours),
+		// The LUCI Project.
+		field.String("project"),
+		field.Bool("joined"))
+)
+
+func init() {
+	// Register metrics as global metrics, which has the effort of
+	// resetting them after every flush.
+	tsmon.RegisterGlobalCallback(func(ctx context.Context) {
+		// Do nothing -- the metrics will be populated by the cron
+		// job itself and does not need to be triggered externally.
+	}, activeRulesGauge, joinToBuildGauge, joinToPresubmitGauge)
+}
+
+// GlobalMetrics handles the "global-metrics" cron job. It reports
+// metrics related to overall system state (that are not logically
+// reported as part of individual task or cron job executions).
+func GlobalMetrics(ctx context.Context) error {
+	projectConfigs, err := config.Projects(ctx)
+	if err != nil {
+		return errors.Annotate(err, "obtain project configs").Err()
+	}
+
+	// Total number of active rules, broken down by project.
+	activeRules, err := rules.ReadTotalActiveRules(span.Single(ctx))
+	if err != nil {
+		return errors.Annotate(err, "collect total active rules").Err()
+	}
+	for project := range projectConfigs {
+		// If there is no entry in activeRules for this project
+		// (e.g. because there are no rules in that project),
+		// the read count defaults to zero, which is the correct
+		// behaviour.
+		count := activeRules[project]
+		activeRulesGauge.Set(ctx, count, project)
+	}
+
+	// Performance joining to buildbucket builds in ingestion.
+	buildJoinStats, err := control.ReadBuildJoinStatistics(span.Single(ctx))
+	if err != nil {
+		return errors.Annotate(err, "collect buildbucket build join statistics").Err()
+	}
+	reportJoinStats(ctx, joinToBuildGauge, buildJoinStats)
+
+	// Performance joining to presubmit runs in ingestion.
+	psRunJoinStats, err := control.ReadPresubmitRunJoinStatistics(span.Single(ctx))
+	if err != nil {
+		return errors.Annotate(err, "collect presubmit run join statistics").Err()
+	}
+	reportJoinStats(ctx, joinToPresubmitGauge, psRunJoinStats)
+
+	return nil
+}
+
+func reportJoinStats(ctx context.Context, metric metric.NonCumulativeDistribution, resultsByProject map[string]control.JoinStatistics) {
+	for project, stats := range resultsByProject {
+		joinedDist := distribution.New(metric.Bucketer())
+		unjoinedDist := distribution.New(metric.Bucketer())
+
+		for hoursAgo := 0; hoursAgo < control.JoinStatsHours; hoursAgo++ {
+			joinedBuilds := stats.JoinedByHour[hoursAgo]
+			unjoinedBuilds := stats.TotalByHour[hoursAgo] - joinedBuilds
+			for i := int64(0); i < joinedBuilds; i++ {
+				joinedDist.Add(float64(hoursAgo))
+			}
+			for i := int64(0); i < unjoinedBuilds; i++ {
+				unjoinedDist.Add(float64(hoursAgo))
+			}
+		}
+
+		joined := true
+		metric.Set(ctx, joinedDist, project, joined)
+		joined = false
+		metric.Set(ctx, unjoinedDist, project, joined)
+	}
+}
diff --git a/analysis/internal/metrics/cron_test.go b/analysis/internal/metrics/cron_test.go
new file mode 100644
index 0000000..5c9bee6
--- /dev/null
+++ b/analysis/internal/metrics/cron_test.go
@@ -0,0 +1,76 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package metrics
+
+import (
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/gae/impl/memory"
+
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+func TestGlobalMetrics(t *testing.T) {
+	Convey(`With Spanner Test Database`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		ctx = memory.Use(ctx) // For project config in datastore.
+
+		// Setup project configuration.
+		projectCfgs := map[string]*configpb.ProjectConfig{
+			"project-a": {},
+			"project-b": {},
+		}
+		So(config.SetTestProjectConfig(ctx, projectCfgs), ShouldBeNil)
+
+		// Create some active rules.
+		rulesToCreate := []*rules.FailureAssociationRule{
+			rules.NewRule(0).WithProject("project-a").WithActive(true).Build(),
+			rules.NewRule(1).WithProject("project-a").WithActive(true).Build(),
+		}
+		err := rules.SetRulesForTesting(ctx, rulesToCreate)
+		So(err, ShouldBeNil)
+
+		// Create some ingestion control records.
+		reference := time.Now().Add(-1 * time.Minute)
+		entriesToCreate := []*control.Entry{
+			control.NewEntry(0).
+				WithBuildProject("project-a").
+				WithPresubmitProject("project-b").
+				WithBuildJoinedTime(reference).
+				WithPresubmitJoinedTime(reference).
+				Build(),
+			control.NewEntry(1).
+				WithBuildProject("project-b").
+				WithBuildJoinedTime(reference).
+				WithPresubmitResult(nil).Build(),
+			control.NewEntry(2).
+				WithPresubmitProject("project-a").
+				WithPresubmitJoinedTime(reference).
+				WithBuildResult(nil).Build(),
+		}
+		_, err = control.SetEntriesForTesting(ctx, entriesToCreate...)
+		So(err, ShouldBeNil)
+
+		err = GlobalMetrics(ctx)
+		So(err, ShouldBeNil)
+	})
+}
diff --git a/analysis/internal/metrics/main_test.go b/analysis/internal/metrics/main_test.go
new file mode 100644
index 0000000..c921465
--- /dev/null
+++ b/analysis/internal/metrics/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package metrics
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/pagination/page_size.go b/analysis/internal/pagination/page_size.go
new file mode 100644
index 0000000..9bc0257
--- /dev/null
+++ b/analysis/internal/pagination/page_size.go
@@ -0,0 +1,46 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pagination
+
+import (
+	"go.chromium.org/luci/common/errors"
+)
+
+type PageSizeLimiter struct {
+	Max     int32
+	Default int32
+}
+
+// Adjust the requested pageSize according to PageSizeLimiter.Max and
+// PageSizeLimiter.Default as necessary.
+func (psl *PageSizeLimiter) Adjust(pageSize int32) int32 {
+	switch {
+	case pageSize >= psl.Max:
+		return psl.Max
+	case pageSize > 0:
+		return pageSize
+	default:
+		return psl.Default
+	}
+}
+
+// ValidatePageSize returns a non-nil error if pageSize is invalid.
+// Returns nil if pageSize is 0.
+func ValidatePageSize(pageSize int32) error {
+	if pageSize < 0 {
+		return errors.Reason("negative").Err()
+	}
+	return nil
+}
diff --git a/analysis/internal/pagination/page_size_test.go b/analysis/internal/pagination/page_size_test.go
new file mode 100644
index 0000000..3772a9d
--- /dev/null
+++ b/analysis/internal/pagination/page_size_test.go
@@ -0,0 +1,56 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pagination
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestPageSizeLimiter(t *testing.T) {
+	t.Parallel()
+
+	Convey(`PageSizeLimiter`, t, func() {
+		psl := PageSizeLimiter{
+			Max:     1000,
+			Default: 10,
+		}
+
+		Convey(`Adjust works`, func() {
+			So(psl.Adjust(0), ShouldEqual, 10)
+			So(psl.Adjust(10000), ShouldEqual, 1000)
+			So(psl.Adjust(500), ShouldEqual, 500)
+			So(psl.Adjust(5), ShouldEqual, 5)
+		})
+	})
+}
+
+func TestValidatePageSize(t *testing.T) {
+	t.Parallel()
+
+	Convey(`ValidatePageSize`, t, func() {
+		Convey(`Positive`, func() {
+			So(ValidatePageSize(10), ShouldBeNil)
+		})
+		Convey(`Zero`, func() {
+			So(ValidatePageSize(0), ShouldBeNil)
+		})
+		Convey(`Negative`, func() {
+			So(ValidatePageSize(-10), ShouldErrLike, "negative")
+		})
+	})
+}
diff --git a/analysis/internal/pagination/page_token.go b/analysis/internal/pagination/page_token.go
new file mode 100644
index 0000000..247e6bf
--- /dev/null
+++ b/analysis/internal/pagination/page_token.go
@@ -0,0 +1,64 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pagination
+
+import (
+	"encoding/base64"
+
+	"google.golang.org/grpc/codes"
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/grpc/appstatus"
+
+	paginationpb "go.chromium.org/luci/analysis/internal/pagination/proto"
+)
+
+// ParseToken extracts a string slice position from the given page token.
+// May return an appstatus-annotated error.
+func ParseToken(token string) ([]string, error) {
+	if token == "" {
+		return nil, nil
+	}
+
+	tokBytes, err := base64.StdEncoding.DecodeString(token)
+	if err != nil {
+		return nil, InvalidToken(err)
+	}
+
+	msg := &paginationpb.PageToken{}
+	if err := proto.Unmarshal(tokBytes, msg); err != nil {
+		return nil, InvalidToken(err)
+	}
+	return msg.Position, nil
+}
+
+// Token converts an string slice representing page token position to an opaque
+// token string.
+func Token(pos ...string) string {
+	if pos == nil {
+		return ""
+	}
+
+	msgBytes, err := proto.Marshal(&paginationpb.PageToken{Position: pos})
+	if err != nil {
+		panic(err)
+	}
+	return base64.StdEncoding.EncodeToString(msgBytes)
+}
+
+// InvalidToken annotates the error with InvalidArgument appstatus.
+func InvalidToken(err error) error {
+	return appstatus.Attachf(err, codes.InvalidArgument, "invalid page_token")
+}
diff --git a/analysis/internal/pagination/page_token_test.go b/analysis/internal/pagination/page_token_test.go
new file mode 100644
index 0000000..eda5545
--- /dev/null
+++ b/analysis/internal/pagination/page_token_test.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pagination
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestPageToken(t *testing.T) {
+	t.Parallel()
+
+	Convey(`Token works`, t, func() {
+		So(Token("v1", "v2"), ShouldResemble, "CgJ2MQoCdjI=")
+
+		pos, err := ParseToken("CgJ2MQoCdjI=")
+		So(err, ShouldBeNil)
+		So(pos, ShouldResemble, []string{"v1", "v2"})
+
+		Convey(`For fresh token`, func() {
+			So(Token(), ShouldResemble, "")
+
+			pos, err := ParseToken("")
+			So(err, ShouldBeNil)
+			So(pos, ShouldBeNil)
+		})
+	})
+}
diff --git a/analysis/internal/pagination/proto/gen.go b/analysis/internal/pagination/proto/gen.go
new file mode 100644
index 0000000..0ade979
--- /dev/null
+++ b/analysis/internal/pagination/proto/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package paginationpb
+
+//go:generate cproto -use-grpc-plugin
diff --git a/analysis/internal/pagination/proto/pagination.pb.go b/analysis/internal/pagination/proto/pagination.pb.go
new file mode 100644
index 0000000..9a4d640
--- /dev/null
+++ b/analysis/internal/pagination/proto/pagination.pb.go
@@ -0,0 +1,170 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.27.1
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/pagination/proto/pagination.proto
+
+package paginationpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// A message for storing all the information attached to a page token.
+type PageToken struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Position marks the cursor's start (exclusive). Its interpretation is
+	// implementation-specific. For instance, for a Spanner cursor, this is a
+	// string slice representation of the Spanner key corresponding to the entry
+	// prior to the one at which to start reading, or empty if the cursor is to
+	// start at the beginning.
+	Position []string `protobuf:"bytes,1,rep,name=position,proto3" json:"position,omitempty"`
+}
+
+func (x *PageToken) Reset() {
+	*x = PageToken{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PageToken) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PageToken) ProtoMessage() {}
+
+func (x *PageToken) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use PageToken.ProtoReflect.Descriptor instead.
+func (*PageToken) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *PageToken) GetPosition() []string {
+	if x != nil {
+		return x.Position
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_internal_pagination_proto_pagination_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDesc = []byte{
+	0x0a, 0x42, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x12, 0x1b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e,
+	0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x22, 0x27, 0x0a, 0x09, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x1a,
+	0x0a, 0x08, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x08, 0x70, 0x6f, 0x73, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x40, 0x5a, 0x3e, 0x69, 0x6e,
+	0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70,
+	0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x3b,
+	0x70, 0x61, 0x67, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescData = file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_goTypes = []interface{}{
+	(*PageToken)(nil), // 0: weetbix.internal.pagination.PageToken
+}
+var file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_depIdxs = []int32{
+	0, // [0:0] is the sub-list for method output_type
+	0, // [0:0] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_init() }
+func file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_init() {
+	if File_infra_appengine_weetbix_internal_pagination_proto_pagination_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*PageToken); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_internal_pagination_proto_pagination_proto = out.File
+	file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_rawDesc = nil
+	file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_goTypes = nil
+	file_infra_appengine_weetbix_internal_pagination_proto_pagination_proto_depIdxs = nil
+}
diff --git a/analysis/internal/pagination/proto/pagination.proto b/analysis/internal/pagination/proto/pagination.proto
new file mode 100644
index 0000000..3326303
--- /dev/null
+++ b/analysis/internal/pagination/proto/pagination.proto
@@ -0,0 +1,29 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.internal.pagination;
+
+option go_package = "go.chromium.org/luci/analysis/internal/pagination/proto;paginationpb";
+
+// A message for storing all the information attached to a page token.
+message PageToken {
+  // Position marks the cursor's start (exclusive). Its interpretation is
+  // implementation-specific. For instance, for a Spanner cursor, this is a
+  // string slice representation of the Spanner key corresponding to the entry
+  // prior to the one at which to start reading, or empty if the cursor is to
+  // start at the beginning.
+  repeated string position = 1;
+}
diff --git a/analysis/internal/perms/perms.go b/analysis/internal/perms/perms.go
new file mode 100644
index 0000000..31d309a
--- /dev/null
+++ b/analysis/internal/perms/perms.go
@@ -0,0 +1,119 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package perms defines permissions used to control access to Weetbix
+// resources, and related methods.
+package perms
+
+import (
+	"go.chromium.org/luci/resultdb/rdbperms"
+	"go.chromium.org/luci/server/auth/realms"
+)
+
+// All permissions in this file are checked against "<luciproject>:@root"
+// realm, as rules and clusters do not live in any particular realm.
+
+// Permissions that should usually be granted to all users that can view
+// a project.
+var (
+	// Grants access to reading individual Weetbix rules in a LUCI project,
+	// except for the rule definition (i.e. 'reason LIKE "%criteria%"'.).
+	//
+	// This also permits the user to see the identity of the configured
+	// issue tracker for a project. (This is available via the URL
+	// provided for bugs on a rule and via a separate config RPC.)
+	PermGetRule = realms.RegisterPermission("weetbix.rules.get")
+
+	// Grants access to listing all rules in a LUCI project,
+	// except for the rule definition (i.e. 'reason LIKE "%criteria%"'.).
+	//
+	// This also permits the user to see the identity of the configured
+	// issue tracker for a project. (This is available via the URL
+	// provided for bugs on a rule.)
+	PermListRules = realms.RegisterPermission("weetbix.rules.list")
+
+	// Grants permission to get a cluster in a project.
+	// This encompasses the cluster ID and aggregated impact for
+	// the cluster (over all failures, not just those the user can see).
+	//
+	// Seeing failures in a cluster is contingent on also having
+	// having "resultdb.testResults.list" permission in ResultDB
+	// for the realm of the test result.
+	//
+	// This permission also allows the user to obtain Weetbix's
+	// progress reclustering failures to reflect new rules, configuration
+	// and algorithms.
+	PermGetCluster = realms.RegisterPermission("weetbix.clusters.get")
+
+	// Grants permission to list all clusters in a project.
+	// This encompasses the cluster identifier and aggregated impact for
+	// the clusters (over all failures, not just those the user can see).
+	// More detailed cluster information, including cluster definition
+	// and failures is contingent on being able to see failures in the
+	// cluster.
+	PermListClusters = realms.RegisterPermission("weetbix.clusters.list")
+
+	// PermGetClustersByFailure allows the user to obtain the cluster
+	// identit(ies) matching a given failure.
+	PermGetClustersByFailure = realms.RegisterPermission("weetbix.clusters.getByFailure")
+
+	// Grants permission to get project configuration, such
+	// as the configured monorail issue tracker. Controls the
+	// visibility of the project in the Weetbix main page.
+	//
+	// Can be assumed this is also granted wherever a project has
+	// a weetbix.rules.* or weetbix.clusters.* CRUD permission;
+	// many parts of Weetbix rely on Weetbix configuration and
+	// there is no need to perform gratuitous access checks.
+	PermGetConfig = realms.RegisterPermission("weetbix.config.get")
+)
+
+// The following permission grants view access to the rule definition,
+// which could be sensitive if test names or failure reasons reveal
+// sensitive product or hardware data.
+var (
+	// Grants access to reading the rule definition of Weetbix rules.
+	PermGetRuleDefinition = realms.RegisterPermission("weetbix.rules.getDefinition")
+)
+
+// Mutating permissions.
+var (
+	// Grants permission to create a rule.
+	// Should be granted only to trusted project contributors.
+	PermCreateRule = realms.RegisterPermission("weetbix.rules.create")
+
+	// Grants permission to update all rules in a project.
+	// Permission to update a rule also implies permission to get the rule
+	// and view the rule definition as the modified rule is returned in the
+	// response to the UpdateRule RPC.
+	// Should be granted only to trusted project contributors.
+	PermUpdateRule = realms.RegisterPermission("weetbix.rules.update")
+)
+
+// Permissions used to control costs.
+var (
+	// Grants permission to perform expensive queries (that hit BigQuery).
+	PermExpensiveClusterQueries = realms.RegisterPermission("weetbix.clusters.expensiveQueries")
+)
+
+// Permissions used to control access to test results.
+var ListTestResultsAndExonerations = []realms.Permission{
+	rdbperms.PermListTestResults,
+	rdbperms.PermListTestExonerations,
+}
+
+func init() {
+	rdbperms.PermListTestResults.AddFlags(realms.UsedInQueryRealms)
+	rdbperms.PermListTestExonerations.AddFlags(realms.UsedInQueryRealms)
+}
diff --git a/analysis/internal/perms/utils.go b/analysis/internal/perms/utils.go
new file mode 100644
index 0000000..c9b0227
--- /dev/null
+++ b/analysis/internal/perms/utils.go
@@ -0,0 +1,201 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package perms
+
+import (
+	"context"
+	"strings"
+
+	"go.chromium.org/luci/common/data/stringset"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/grpc/appstatus"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/realms"
+	"google.golang.org/grpc/codes"
+)
+
+var (
+	ErrInvalidRealm     = errors.New("realm must be in the format <project>:<realm>")
+	ErrMultipleProjects = errors.New("all realms must be from the same projects")
+)
+
+// SplitRealm splits the realm into the LUCI project name and the (sub)realm.
+// Returns ErrInvalidRealm if the provided realm doesn't have a valid format.
+func SplitRealm(realm string) (proj string, subRealm string, err error) {
+	parts := strings.SplitN(realm, ":", 2)
+	if len(parts) != 2 {
+		return "", "", ErrInvalidRealm
+	}
+	return parts[0], parts[1], nil
+}
+
+// SplitRealms splits the realms into the LUCI project name and the (sub)realms.
+// All realms must belong to the same project.
+//
+// Returns ErrInvalidRealm if any of the realm doesn't have a valid format.
+// Returns ErrMultipleProjects if not all realms are from the same project.
+func SplitRealms(realms []string) (proj string, subRealms []string, err error) {
+	if len(realms) == 0 {
+		return "", nil, nil
+	}
+
+	subRealms = make([]string, 0, len(realms))
+	proj, subRealm, err := SplitRealm(realms[0])
+	if err != nil {
+		return "", nil, ErrInvalidRealm
+	}
+	subRealms = append(subRealms, subRealm)
+	for _, realm := range realms[1:] {
+		currentProj, subRealm, err := SplitRealm(realm)
+		if err != nil {
+			return "", nil, ErrInvalidRealm
+		}
+		if currentProj != proj {
+			return "", nil, ErrMultipleProjects
+		}
+		subRealms = append(subRealms, subRealm)
+
+	}
+	return proj, subRealms, nil
+}
+
+// VerifyPermissions is a wrapper around luci/server/auth.HasPermission that checks
+// whether the user has all the listed permissions and return an appstatus
+// annotated error if users have no permission.
+func VerifyPermissions(ctx context.Context, realm string, attrs realms.Attrs, permissions ...realms.Permission) error {
+	for _, perm := range permissions {
+		allowed, err := auth.HasPermission(ctx, perm, realm, attrs)
+		if err != nil {
+			return err
+		}
+		if !allowed {
+			return appstatus.Errorf(codes.PermissionDenied, `caller does not have permission %s in realm %q`, perm, realm)
+		}
+	}
+	return nil
+}
+
+// VerifyProjectPermissions verifies the caller has the given permissions in the
+// @root realm of the given project. If the caller does not have permission,
+// an appropriate appstatus error is returned, which should be returned
+// immediately to the RPC caller.
+func VerifyProjectPermissions(ctx context.Context, project string, permissions ...realms.Permission) error {
+	realm := project + ":@root"
+	for _, p := range permissions {
+		allowed, err := HasProjectPermission(ctx, project, p)
+		if err != nil {
+			return err
+		}
+		if !allowed {
+			return appstatus.Errorf(codes.PermissionDenied, `caller does not have permission %s in realm %q`, p, realm)
+		}
+	}
+	return nil
+}
+
+// HasProjectPermission returns if the caller has the given permission in
+// the @root realm of the given project. This method only returns an error
+// if there is some AuthDB issue.
+func HasProjectPermission(ctx context.Context, project string, permission realms.Permission) (bool, error) {
+	realm := project + ":@root"
+	switch allowed, err := auth.HasPermission(ctx, permission, realm, nil); {
+	case err != nil:
+		return false, err
+	case !allowed:
+		return false, nil
+	}
+	return true, nil
+}
+
+// QueryRealms is a wrapper around luci/server/auth.QueryRealms that returns a
+// list of realms where the current caller has all the listed permissions.
+//
+// A project is required.
+//
+// The permissions should be flagged in the process with UsedInQueryRealms
+// flag, which lets the runtime know it must prepare indexes for the
+// corresponding QueryRealms call.
+func QueryRealms(ctx context.Context, project string, attrs realms.Attrs, permissions ...realms.Permission) ([]string, error) {
+	if project == "" {
+		return nil, errors.New("project must be specified")
+	}
+	if len(permissions) == 0 {
+		return nil, errors.New("at least one permission must be specified")
+	}
+
+	allowedRealms, err := auth.QueryRealms(ctx, permissions[0], project, attrs)
+	if err != nil {
+		return nil, err
+	}
+	allowedRealmSet := stringset.NewFromSlice(allowedRealms...)
+
+	for _, perm := range permissions[1:] {
+		allowedRealms, err := auth.QueryRealms(ctx, perm, project, attrs)
+		if err != nil {
+			return nil, err
+		}
+		allowedRealmSet = allowedRealmSet.Intersect(stringset.NewFromSlice(allowedRealms...))
+	}
+
+	return allowedRealmSet.ToSortedSlice(), nil
+}
+
+// QueryRealmsNonEmpty is similar to QueryRealms but it returns an
+// appstatus annotated error if there are no realms
+// that the user has all of the given permissions in.
+func QueryRealmsNonEmpty(ctx context.Context, project string, attrs realms.Attrs, permissions ...realms.Permission) ([]string, error) {
+	realms, err := QueryRealms(ctx, project, attrs, permissions...)
+	if err != nil {
+		return nil, err
+	}
+	if len(realms) == 0 {
+		return nil, appstatus.Errorf(codes.PermissionDenied, `caller does not have permissions %v in any realm in project %q`, permissions, project)
+	}
+	return realms, nil
+}
+
+// QuerySubRealmsNonEmpty is similar to QueryRealmsNonEmpty with the following differences:
+//  1. an optional subRealm argument allows results to be limited to a
+//     specific realm (matching `<project>:<subRealm>`).
+//  2. a list of subRealms is returned instead of a list of realms
+//    (e.g. ["realm1", "realm2"] instead of ["project:realm1", "project:realm2"])
+func QuerySubRealmsNonEmpty(ctx context.Context, project, subRealm string, attrs realms.Attrs, permissions ...realms.Permission) ([]string, error) {
+	if project == "" {
+		return nil, errors.New("project must be specified")
+	}
+	if len(permissions) == 0 {
+		return nil, errors.New("at least one permission must be specified")
+	}
+
+	if subRealm != "" {
+		realm := project + ":" + subRealm
+		if err := VerifyPermissions(ctx, realm, attrs, permissions...); err != nil {
+			return nil, err
+		}
+		return []string{subRealm}, nil
+	}
+
+	realms, err := QueryRealmsNonEmpty(ctx, project, attrs, permissions...)
+	if err != nil {
+		return nil, err
+	}
+	_, subRealms, err := SplitRealms(realms)
+	if err != nil {
+		// Realms returned by `QueryRealms` should always be valid.
+		// This should never happen.
+		panic(err)
+	}
+	return subRealms, nil
+}
diff --git a/analysis/internal/perms/utils_test.go b/analysis/internal/perms/utils_test.go
new file mode 100644
index 0000000..6c5cd77
--- /dev/null
+++ b/analysis/internal/perms/utils_test.go
@@ -0,0 +1,173 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package perms
+
+import (
+	"context"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/resultdb/rdbperms"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/auth/realms"
+	"google.golang.org/grpc/codes"
+)
+
+func init() {
+	rdbperms.PermListTestResults.AddFlags(realms.UsedInQueryRealms)
+	rdbperms.PermListTestExonerations.AddFlags(realms.UsedInQueryRealms)
+	rdbperms.PermGetArtifact.AddFlags(realms.UsedInQueryRealms)
+	rdbperms.PermListArtifacts.AddFlags(realms.UsedInQueryRealms)
+}
+
+func TestQueryRealms(t *testing.T) {
+	Convey("QueryRealms", t, func() {
+		ctx := context.Background()
+
+		ctx = auth.WithState(ctx, &authtest.FakeState{
+			Identity: "user:someone@example.com",
+			IdentityPermissions: []authtest.RealmPermission{
+				{
+					Realm:      "project1:realm1",
+					Permission: rdbperms.PermListTestResults,
+				},
+				{
+					Realm:      "project1:realm1",
+					Permission: rdbperms.PermListTestExonerations,
+				},
+				{
+					Realm:      "project1:realm1",
+					Permission: rdbperms.PermGetArtifact,
+				},
+				{
+					Realm:      "project1:realm2",
+					Permission: rdbperms.PermListTestResults,
+				},
+				{
+					Realm:      "project1:realm2",
+					Permission: rdbperms.PermListTestExonerations,
+				},
+				{
+					Realm:      "project2:realm1",
+					Permission: rdbperms.PermListTestResults,
+				},
+				{
+					Realm:      "project2:realm1",
+					Permission: rdbperms.PermGetArtifact,
+				},
+				{
+					Realm:      "project2:realm1",
+					Permission: rdbperms.PermListArtifacts,
+				},
+			},
+		})
+
+		Convey("QueryRealms", func() {
+			Convey("no permission specified", func() {
+				realms, err := QueryRealms(ctx, "project1", nil)
+				So(err, ShouldErrLike, "at least one permission must be specified")
+				So(realms, ShouldBeEmpty)
+			})
+
+			Convey("no project specified", func() {
+				realms, err := QueryRealms(ctx, "", nil, rdbperms.PermListTestResults)
+				So(err, ShouldErrLike, "project must be specified")
+				So(realms, ShouldBeEmpty)
+			})
+
+			Convey("check single permission", func() {
+				realms, err := QueryRealms(ctx, "project1", nil, rdbperms.PermListTestResults)
+				So(err, ShouldBeNil)
+				So(realms, ShouldResemble, []string{"project1:realm1", "project1:realm2"})
+			})
+
+			Convey("check multiple permissions", func() {
+				realms, err := QueryRealms(ctx, "project1", nil, rdbperms.PermListTestResults, rdbperms.PermGetArtifact)
+				So(err, ShouldBeNil)
+				So(realms, ShouldResemble, []string{"project1:realm1"})
+			})
+
+			Convey("no matched realms", func() {
+				realms, err := QueryRealms(ctx, "project1", nil, rdbperms.PermListTestExonerations, rdbperms.PermListArtifacts)
+				So(err, ShouldBeNil)
+				So(realms, ShouldBeEmpty)
+			})
+
+			Convey("no matched realms with non-empty method variant", func() {
+				realms, err := QueryRealmsNonEmpty(ctx, "project1", nil, rdbperms.PermListTestExonerations, rdbperms.PermListArtifacts)
+				So(err, ShouldErrLike, "caller does not have permissions", "in any realm in project \"project1\"")
+				So(err, ShouldHaveAppStatus, codes.PermissionDenied)
+				So(realms, ShouldBeEmpty)
+			})
+		})
+		Convey("QuerySubRealms", func() {
+			Convey("no permission specified", func() {
+				realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "realm1", nil)
+				So(err, ShouldErrLike, "at least one permission must be specified")
+				So(realms, ShouldBeEmpty)
+			})
+
+			Convey("no project specified", func() {
+				realms, err := QuerySubRealmsNonEmpty(ctx, "", "", nil, rdbperms.PermListTestResults)
+				So(err, ShouldErrLike, "project must be specified")
+				So(realms, ShouldBeEmpty)
+			})
+
+			Convey("project scope", func() {
+				Convey("check single permission", func() {
+					realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "", nil, rdbperms.PermListTestResults)
+					So(err, ShouldBeNil)
+					So(realms, ShouldResemble, []string{"realm1", "realm2"})
+				})
+
+				Convey("check multiple permissions", func() {
+					realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "", nil, rdbperms.PermListTestResults, rdbperms.PermGetArtifact)
+					So(err, ShouldBeNil)
+					So(realms, ShouldResemble, []string{"realm1"})
+				})
+
+				Convey("no matched realms", func() {
+					realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "", nil, rdbperms.PermListTestExonerations, rdbperms.PermListArtifacts)
+					So(err, ShouldErrLike, "caller does not have permissions", "in any realm in project \"project1\"")
+					So(err, ShouldHaveAppStatus, codes.PermissionDenied)
+					So(realms, ShouldBeEmpty)
+				})
+			})
+
+			Convey("realm scope", func() {
+				Convey("check single permission", func() {
+					realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "realm1", nil, rdbperms.PermListTestResults)
+					So(err, ShouldBeNil)
+					So(realms, ShouldResemble, []string{"realm1"})
+				})
+
+				Convey("check multiple permissions", func() {
+					realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "realm1", nil, rdbperms.PermListTestResults, rdbperms.PermGetArtifact)
+					So(err, ShouldBeNil)
+					So(realms, ShouldResemble, []string{"realm1"})
+				})
+
+				Convey("no matched realms", func() {
+					realms, err := QuerySubRealmsNonEmpty(ctx, "project1", "realm1", nil, rdbperms.PermListTestExonerations, rdbperms.PermListArtifacts)
+					So(err, ShouldErrLike, "caller does not have permission", "in realm \"project1:realm1\"")
+					So(err, ShouldHaveAppStatus, codes.PermissionDenied)
+					So(realms, ShouldBeEmpty)
+				})
+			})
+		})
+	})
+}
diff --git a/analysis/internal/resultdb/resultdb.go b/analysis/internal/resultdb/resultdb.go
new file mode 100644
index 0000000..bdef536
--- /dev/null
+++ b/analysis/internal/resultdb/resultdb.go
@@ -0,0 +1,117 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package resultdb contains logic of interacting with resultdb.
+package resultdb
+
+import (
+	"context"
+	"net/http"
+
+	"go.chromium.org/luci/grpc/prpc"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/auth"
+	"google.golang.org/protobuf/proto"
+)
+
+// mockResultDBClientKey is the context key indicates using mocked resultb client in tests.
+var mockResultDBClientKey = "used in tests only for setting the mock resultdb client"
+
+func newResultDBClient(ctx context.Context, host string) (rdbpb.ResultDBClient, error) {
+	if mockClient, ok := ctx.Value(&mockResultDBClientKey).(*rdbpb.MockResultDBClient); ok {
+		return mockClient, nil
+	}
+
+	t, err := auth.GetRPCTransport(ctx, auth.AsSelf)
+	if err != nil {
+		return nil, err
+	}
+	return rdbpb.NewResultDBPRPCClient(
+		&prpc.Client{
+			C:                &http.Client{Transport: t},
+			Host:             host,
+			Options:          prpc.DefaultOptions(),
+			MaxContentLength: 100 * 1000 * 1000, // 100 MiB.
+		}), nil
+}
+
+// Client is the client to communicate with ResultDB.
+// It wraps a rdbpb.ResultDBClient.
+type Client struct {
+	client rdbpb.ResultDBClient
+}
+
+// NewClient creates a client to communicate with ResultDB.
+func NewClient(ctx context.Context, host string) (*Client, error) {
+	client, err := newResultDBClient(ctx, host)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Client{
+		client: client,
+	}, nil
+}
+
+// QueryTestVariants queries a single page of test variants.
+func (c *Client) QueryTestVariants(ctx context.Context, req *rdbpb.QueryTestVariantsRequest) (*rdbpb.QueryTestVariantsResponse, error) {
+	return c.client.QueryTestVariants(ctx, req)
+}
+
+// QueryTestVariantsMany queries test variants and advances the page automatically.
+//
+// f is called once per page of test variants.
+func (c *Client) QueryTestVariantsMany(ctx context.Context, req *rdbpb.QueryTestVariantsRequest, f func([]*rdbpb.TestVariant) error, maxPages int) error {
+	// Copy the request to avoid aliasing issues when we update the page token.
+	req = proto.Clone(req).(*rdbpb.QueryTestVariantsRequest)
+
+	for page := 0; page < maxPages; page++ {
+		rsp, err := c.client.QueryTestVariants(ctx, req)
+		if err != nil {
+			return err
+		}
+
+		if err = f(rsp.TestVariants); err != nil {
+			return err
+		}
+
+		req.PageToken = rsp.GetNextPageToken()
+		if req.PageToken == "" {
+			// No more test variants.
+			break
+		}
+	}
+
+	return nil
+}
+
+// GetInvocation retrieves the invocation.
+func (c *Client) GetInvocation(ctx context.Context, invName string) (*rdbpb.Invocation, error) {
+	inv, err := c.client.GetInvocation(ctx, &rdbpb.GetInvocationRequest{
+		Name: invName,
+	})
+	if err != nil {
+		return nil, err
+	}
+	return inv, nil
+}
+
+// BatchGetTestVariants retrieves the requested test variants.
+func (c *Client) BatchGetTestVariants(ctx context.Context, req *rdbpb.BatchGetTestVariantsRequest) ([]*rdbpb.TestVariant, error) {
+	rsp, err := c.client.BatchGetTestVariants(ctx, req)
+	if err != nil {
+		return nil, err
+	}
+	return rsp.GetTestVariants(), nil
+}
diff --git a/analysis/internal/resultdb/resultdb_test.go b/analysis/internal/resultdb/resultdb_test.go
new file mode 100644
index 0000000..171e1b4
--- /dev/null
+++ b/analysis/internal/resultdb/resultdb_test.go
@@ -0,0 +1,123 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultdb
+
+import (
+	"context"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+)
+
+func TestResultDB(t *testing.T) {
+	t.Parallel()
+	Convey(`resultdb`, t, func() {
+		ctl := gomock.NewController(t)
+		defer ctl.Finish()
+		mc := NewMockedClient(context.Background(), ctl)
+		rc, err := NewClient(mc.Ctx, "rdbhost")
+		So(err, ShouldBeNil)
+
+		inv := "invocations/build-87654321"
+		Convey(`QueryTestVariantsMany`, func() {
+			req := &rdbpb.QueryTestVariantsRequest{
+				Invocations: []string{inv},
+				PageSize:    1000,
+				Predicate: &rdbpb.TestVariantPredicate{
+					Status: rdbpb.TestVariantStatus_UNEXPECTED_MASK,
+				},
+			}
+
+			res := &rdbpb.QueryTestVariantsResponse{
+				TestVariants: []*rdbpb.TestVariant{
+					{
+						TestId:      "ninja://test1",
+						VariantHash: "hash1",
+						Status:      rdbpb.TestVariantStatus_UNEXPECTED,
+					},
+					{
+						TestId:      "ninja://test2",
+						VariantHash: "hash2",
+						Status:      rdbpb.TestVariantStatus_FLAKY,
+					},
+				},
+			}
+			mc.QueryTestVariants(req, res)
+
+			maxPages := 1
+			var tvs []*rdbpb.TestVariant
+			err := rc.QueryTestVariantsMany(mc.Ctx, req, func(res []*rdbpb.TestVariant) error {
+				tvs = append(tvs, res...)
+				return nil
+			}, maxPages)
+			So(err, ShouldBeNil)
+			So(len(tvs), ShouldEqual, 2)
+		})
+
+		Convey(`GetInvocation`, func() {
+			realm := "realm"
+			req := &rdbpb.GetInvocationRequest{
+				Name: inv,
+			}
+			res := &rdbpb.Invocation{
+				Name:  inv,
+				Realm: realm,
+			}
+			mc.GetInvocation(req, res)
+
+			invProto, err := rc.GetInvocation(mc.Ctx, inv)
+			So(err, ShouldBeNil)
+			So(invProto, ShouldResembleProto, res)
+		})
+
+		Convey(`BatchGetTestVariants`, func() {
+			req := &rdbpb.BatchGetTestVariantsRequest{
+				Invocation: inv,
+				TestVariants: []*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier{
+					{
+						TestId:      "ninja://test1",
+						VariantHash: "hash1",
+					},
+					{
+						TestId:      "ninja://test2",
+						VariantHash: "hash2",
+					},
+				},
+			}
+
+			res := &rdbpb.BatchGetTestVariantsResponse{
+				TestVariants: []*rdbpb.TestVariant{
+					{
+						TestId:      "ninja://test1",
+						VariantHash: "hash1",
+						Status:      rdbpb.TestVariantStatus_UNEXPECTED,
+					},
+					{
+						TestId:      "ninja://test2",
+						VariantHash: "hash2",
+						Status:      rdbpb.TestVariantStatus_FLAKY,
+					},
+				},
+			}
+			mc.BatchGetTestVariants(req, res)
+			tvs, err := rc.BatchGetTestVariants(mc.Ctx, req)
+			So(err, ShouldBeNil)
+			So(len(tvs), ShouldEqual, 2)
+		})
+	})
+}
diff --git a/analysis/internal/resultdb/testutil.go b/analysis/internal/resultdb/testutil.go
new file mode 100644
index 0000000..368ba59
--- /dev/null
+++ b/analysis/internal/resultdb/testutil.go
@@ -0,0 +1,69 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultdb
+
+import (
+	"context"
+
+	"github.com/golang/mock/gomock"
+
+	"go.chromium.org/luci/common/proto"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+)
+
+// MockedClient is a mocked ResultDB client for testing.
+// It wraps a rdbpb.MockResultDBClient and a context with the mocked client.
+type MockedClient struct {
+	Client *rdbpb.MockResultDBClient
+	Ctx    context.Context
+}
+
+// NewMockedClient creates a MockedClient for testing.
+func NewMockedClient(ctx context.Context, ctl *gomock.Controller) *MockedClient {
+	mockClient := rdbpb.NewMockResultDBClient(ctl)
+	return &MockedClient{
+		Client: mockClient,
+		Ctx:    context.WithValue(ctx, &mockResultDBClientKey, mockClient),
+	}
+}
+
+// QueryTestVariants mocks the QueryTestVariants RPC.
+func (mc *MockedClient) QueryTestVariants(req *rdbpb.QueryTestVariantsRequest, res *rdbpb.QueryTestVariantsResponse) {
+	mc.Client.EXPECT().QueryTestVariants(gomock.Any(), proto.MatcherEqual(req),
+		gomock.Any()).Return(res, nil)
+}
+
+// GetInvocation mocks the GetInvocation RPC.
+func (mc *MockedClient) GetInvocation(req *rdbpb.GetInvocationRequest, res *rdbpb.Invocation) {
+	mc.Client.EXPECT().GetInvocation(gomock.Any(), proto.MatcherEqual(req),
+		gomock.Any()).Return(res, nil)
+}
+
+// GetRealm is a shortcut of GetInvocation to get realm of the invocation.
+func (mc *MockedClient) GetRealm(inv, realm string) {
+	req := &rdbpb.GetInvocationRequest{
+		Name: inv,
+	}
+	mc.GetInvocation(req, &rdbpb.Invocation{
+		Name:  inv,
+		Realm: realm,
+	})
+}
+
+// BatchGetTestVariants mocks the BatchGetTestVariants RPC.
+func (mc *MockedClient) BatchGetTestVariants(req *rdbpb.BatchGetTestVariantsRequest, res *rdbpb.BatchGetTestVariantsResponse) {
+	mc.Client.EXPECT().BatchGetTestVariants(gomock.Any(), proto.MatcherEqual(req),
+		gomock.Any()).Return(res, nil)
+}
diff --git a/analysis/internal/services/reclustering/reclustering.go b/analysis/internal/services/reclustering/reclustering.go
new file mode 100644
index 0000000..7395af9
--- /dev/null
+++ b/analysis/internal/services/reclustering/reclustering.go
@@ -0,0 +1,106 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"context"
+	"fmt"
+
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/server"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/analysis/clusteredfailures"
+	"go.chromium.org/luci/analysis/internal/clustering/chunkstore"
+	"go.chromium.org/luci/analysis/internal/clustering/reclustering"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+)
+
+const (
+	taskClass = "reclustering"
+	queue     = "reclustering"
+)
+
+var tc = tq.RegisterTaskClass(tq.TaskClass{
+	ID:        taskClass,
+	Prototype: &taskspb.ReclusterChunks{},
+	Queue:     queue,
+	Kind:      tq.NonTransactional,
+})
+
+// RegisterTaskHandler registers the handler for reclustering tasks.
+func RegisterTaskHandler(srv *server.Server) error {
+	ctx := srv.Context
+	cfg, err := config.Get(ctx)
+	if err != nil {
+		return err
+	}
+	chunkStore, err := chunkstore.NewClient(ctx, cfg.ChunkGcsBucket)
+	if err != nil {
+		return err
+	}
+	srv.RegisterCleanup(func(context.Context) {
+		chunkStore.Close()
+	})
+
+	cf, err := clusteredfailures.NewClient(ctx, srv.Options.CloudProject)
+	if err != nil {
+		return err
+	}
+	srv.RegisterCleanup(func(context.Context) {
+		cf.Close()
+	})
+
+	analysis := analysis.NewClusteringHandler(cf)
+	worker := reclustering.NewWorker(chunkStore, analysis)
+
+	handler := func(ctx context.Context, payload proto.Message) error {
+		task := payload.(*taskspb.ReclusterChunks)
+		return reclusterTestResults(ctx, worker, task)
+	}
+	tc.AttachHandler(handler)
+	return nil
+}
+
+// Schedule enqueues a task to recluster a range of chunks in a LUCI
+// Project.
+func Schedule(ctx context.Context, task *taskspb.ReclusterChunks) error {
+	title := fmt.Sprintf("%s-%s-shard-%v", task.Project, task.AttemptTime.AsTime().Format("20060102-150405"), task.EndChunkId)
+	return tq.AddTask(ctx, &tq.Task{
+		Title: title,
+		// Copy the task to avoid the caller retaining an alias to
+		// the task proto passed to tq.AddTask.
+		Payload: proto.Clone(task).(*taskspb.ReclusterChunks),
+	})
+}
+
+func reclusterTestResults(ctx context.Context, worker *reclustering.Worker, task *taskspb.ReclusterChunks) error {
+	next, err := worker.Do(ctx, task, reclustering.TargetTaskDuration)
+	if err != nil {
+		logging.Errorf(ctx, "Error re-clustering: %s", err)
+		return err
+	}
+	if next != nil {
+		if err := Schedule(ctx, next); err != nil {
+			logging.Errorf(ctx, "Error scheduling continuation: %s", err)
+			return err
+		}
+	}
+	return nil
+}
diff --git a/analysis/internal/services/reclustering/reclustering_test.go b/analysis/internal/services/reclustering/reclustering_test.go
new file mode 100644
index 0000000..4db2822
--- /dev/null
+++ b/analysis/internal/services/reclustering/reclustering_test.go
@@ -0,0 +1,49 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package reclustering
+
+import (
+	"strings"
+	"testing"
+	"time"
+
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/server/tq"
+	_ "go.chromium.org/luci/server/tq/txn/spanner"
+
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestSchedule(t *testing.T) {
+	Convey(`TestSchedule`, t, func() {
+		ctx, skdr := tq.TestingContext(testutil.TestingContext(), nil)
+
+		task := &taskspb.ReclusterChunks{
+			Project:      "chromium",
+			AttemptTime:  timestamppb.New(time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC)),
+			StartChunkId: "",
+			EndChunkId:   strings.Repeat("ff", 16),
+		}
+		expected := proto.Clone(task).(*taskspb.ReclusterChunks)
+		So(Schedule(ctx, task), ShouldBeNil)
+		So(skdr.Tasks().Payloads()[0], ShouldResembleProto, expected)
+	})
+}
diff --git a/analysis/internal/services/resultcollector/collect_test_results.go b/analysis/internal/services/resultcollector/collect_test_results.go
new file mode 100644
index 0000000..3eb7d0f
--- /dev/null
+++ b/analysis/internal/services/resultcollector/collect_test_results.go
@@ -0,0 +1,170 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultcollector
+
+import (
+	"context"
+	"fmt"
+
+	"golang.org/x/sync/errgroup"
+	"golang.org/x/sync/semaphore"
+	"google.golang.org/protobuf/proto"
+
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/internal/analyzedtestvariants"
+	"go.chromium.org/luci/analysis/internal/resultdb"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+)
+
+const (
+	taskClass                 = "result-collection"
+	queue                     = "result-collection"
+	maxBatchSize              = 500
+	maxConcurrentBatchRequest = 10
+)
+
+// RegisterTaskClass registers the task class for tq dispatcher.
+func RegisterTaskClass() {
+	tq.RegisterTaskClass(tq.TaskClass{
+		ID:        taskClass,
+		Prototype: &taskspb.CollectTestResults{},
+		Queue:     queue,
+		Kind:      tq.NonTransactional,
+		Handler: func(ctx context.Context, payload proto.Message) error {
+			task := payload.(*taskspb.CollectTestResults)
+			return collectTestResults(ctx, task)
+		},
+	})
+}
+
+// Schedule enqueues a task to get test results of interesting test variants
+// from an invocation.
+//
+// Interesting test variants are the analyzed test variants with any unexpected
+// results.
+func Schedule(ctx context.Context, inv *rdbpb.Invocation, rdbHost, builder string, isPreSubmit, contributedToCLSubmission bool) error {
+	return tq.AddTask(ctx, &tq.Task{
+		Title: fmt.Sprintf("%s", inv.Name),
+		Payload: &taskspb.CollectTestResults{
+			Resultdb: &taskspb.ResultDB{
+				Invocation: inv,
+				Host:       rdbHost,
+			},
+			Builder:                   builder,
+			IsPreSubmit:               isPreSubmit,
+			ContributedToClSubmission: contributedToCLSubmission,
+		},
+	})
+}
+
+func collectTestResults(ctx context.Context, task *taskspb.CollectTestResults) error {
+	client, err := resultdb.NewClient(ctx, task.Resultdb.Host)
+	if err != nil {
+		return err
+	}
+
+	eg, ctx := errgroup.WithContext(ctx)
+	batchC := make(chan []*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier)
+
+	eg.Go(func() error {
+		return batchSaveVerdicts(ctx, task, client, batchC)
+	})
+
+	eg.Go(func() error {
+		defer close(batchC)
+		return queryInterestingTestVariants(ctx, task.Resultdb.Invocation.Realm, task.Builder, batchC)
+	})
+
+	return eg.Wait()
+}
+
+// queryInterestingTestVariants queries analyzed test variants with any
+// unexpected results.
+func queryInterestingTestVariants(ctx context.Context, realm, builder string, batchC chan []*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier) error {
+	ctx, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+
+	tvis := make([]*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier, 0, maxBatchSize)
+	f := func(tv *atvpb.AnalyzedTestVariant) error {
+		tvis = append(tvis, &rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier{
+			TestId:      tv.TestId,
+			VariantHash: tv.VariantHash,
+		})
+
+		if len(tvis) >= maxBatchSize {
+			// Handle a full batch.
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case batchC <- tvis:
+			}
+			tvis = make([]*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier, 0, maxBatchSize)
+		}
+		return nil
+	}
+
+	err := analyzedtestvariants.QueryTestVariantsByBuilder(ctx, realm, builder, f)
+	if err != nil {
+		return err
+	}
+
+	if len(tvis) > 0 {
+		// Handle the last batch.
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case batchC <- tvis:
+		}
+	}
+	return nil
+}
+
+// batchSaveVerdicts batch get test variants from a build invocation and save
+// the results of those test variants in Verdicts.
+func batchSaveVerdicts(ctx context.Context, task *taskspb.CollectTestResults, client *resultdb.Client, batchC chan []*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier) error {
+	eg, ctx := errgroup.WithContext(ctx)
+	defer eg.Wait()
+
+	// Limit the number of concurrent batch requests.
+	sem := semaphore.NewWeighted(maxConcurrentBatchRequest)
+
+	for tvis := range batchC {
+		// See https://github.com/golang/go/wiki/CommonMistakes#using-goroutines-on-loop-iterator-variables
+		tvis := tvis
+		eg.Go(func() error {
+			// Limit concurrent batch requests.
+			if err := sem.Acquire(ctx, 1); err != nil {
+				return err
+			}
+			defer sem.Release(1)
+
+			tvs, err := client.BatchGetTestVariants(ctx, &rdbpb.BatchGetTestVariantsRequest{
+				Invocation:   task.Resultdb.Invocation.Name,
+				TestVariants: tvis,
+			})
+			if err != nil {
+				return err
+			}
+
+			return createVerdicts(ctx, task, tvs)
+		})
+	}
+
+	return eg.Wait()
+}
diff --git a/analysis/internal/services/resultcollector/collect_test_results_test.go b/analysis/internal/services/resultcollector/collect_test_results_test.go
new file mode 100644
index 0000000..956c21d
--- /dev/null
+++ b/analysis/internal/services/resultcollector/collect_test_results_test.go
@@ -0,0 +1,204 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultcollector
+
+import (
+	"fmt"
+	"testing"
+
+	"cloud.google.com/go/spanner"
+	"github.com/golang/mock/gomock"
+
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/resultdb"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+type verdict struct {
+	realm           string
+	testID          string
+	variantHash     string
+	invID           string
+	exonerated      bool
+	status          internal.VerdictStatus
+	unexpectedCount int64
+	totalCount      int64
+}
+
+func TestSchedule(t *testing.T) {
+	Convey(`TestSchedule`, t, func() {
+		ctx, skdr := tq.TestingContext(testutil.TestingContext(), nil)
+		RegisterTaskClass()
+
+		inv := &rdbpb.Invocation{
+			Name:  "invocations/build-87654321",
+			Realm: "chromium:ci",
+		}
+		task := &taskspb.CollectTestResults{
+			Resultdb: &taskspb.ResultDB{
+				Invocation: inv,
+				Host:       "results.api.cr.dev",
+			},
+			Builder:                   "Linux Tests",
+			IsPreSubmit:               false,
+			ContributedToClSubmission: false,
+		}
+		So(Schedule(ctx, inv, task.Resultdb.Host, task.Builder, false, false), ShouldBeNil)
+		So(skdr.Tasks().Payloads()[0], ShouldResembleProto, task)
+	})
+}
+
+func TestSaveVerdicts(t *testing.T) {
+	Convey(`TestSaveVerdicts`, t, func() {
+		ctl := gomock.NewController(t)
+		defer ctl.Finish()
+
+		mrc := resultdb.NewMockedClient(testutil.SpannerTestContext(t), ctl)
+		ctx := mrc.Ctx
+
+		realm := "chromium:ci"
+		builder := "builder"
+		vh := "variant_hash"
+		builderField := map[string]interface{}{
+			"Builder": builder,
+		}
+		// Prepare some analyzed test variants to query.
+		ms := []*spanner.Mutation{
+			insert.AnalyzedTestVariant(realm, "ninja://test_known_flake", vh, atvpb.Status_FLAKY, builderField),
+			insert.AnalyzedTestVariant(realm, "ninja://test_has_unexpected", vh, atvpb.Status_HAS_UNEXPECTED_RESULTS, builderField),
+			insert.AnalyzedTestVariant(realm, "ninja://test_consistent_failure", vh, atvpb.Status_CONSISTENTLY_UNEXPECTED, builderField),
+			// Stale test variant has new failure.
+			insert.AnalyzedTestVariant(realm, "ninja://test_no_new_results", vh, atvpb.Status_NO_NEW_RESULTS, builderField),
+			// Flaky test variant on another builder.
+			insert.AnalyzedTestVariant(realm, "ninja://test_known_flake", "another_hash", atvpb.Status_FLAKY, map[string]interface{}{
+				"Builder": "another_builder",
+			}),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		invID := "build-87654321"
+		invName := fmt.Sprintf("invocations/%s", invID)
+		req := &rdbpb.BatchGetTestVariantsRequest{
+			Invocation: invName,
+			TestVariants: []*rdbpb.BatchGetTestVariantsRequest_TestVariantIdentifier{
+				{
+					TestId:      "ninja://test_consistent_failure",
+					VariantHash: vh,
+				},
+				{
+					TestId:      "ninja://test_has_unexpected",
+					VariantHash: vh,
+				},
+				{
+					TestId:      "ninja://test_known_flake",
+					VariantHash: vh,
+				},
+			},
+		}
+		mrc.BatchGetTestVariants(req, mockedBatchGetTestVariantsResponse())
+
+		inv := &rdbpb.Invocation{
+			Name:       invName,
+			Realm:      realm,
+			CreateTime: pbutil.MustTimestampProto(testclock.TestRecentTimeUTC),
+		}
+		task := &taskspb.CollectTestResults{
+			Resultdb: &taskspb.ResultDB{
+				Invocation: inv,
+				Host:       "results.api.cr.dev",
+			},
+			Builder:                   builder,
+			IsPreSubmit:               false,
+			ContributedToClSubmission: false,
+		}
+		err := collectTestResults(ctx, task)
+		So(err, ShouldBeNil)
+
+		// Read verdicts to confirm they are saved.
+		ctx, cancel := span.ReadOnlyTransaction(ctx)
+		defer cancel()
+
+		ks := spanner.KeySets(
+			spanner.Key{realm, "ninja://test_known_flake", vh, invID},
+			spanner.Key{realm, "ninja://test_consistent_failure", vh, invID},
+			spanner.Key{realm, "ninja://test_has_unexpected", vh, invID},
+		)
+		expected := map[string]verdict{
+			"ninja://test_known_flake": {
+				realm:           realm,
+				testID:          "ninja://test_known_flake",
+				variantHash:     vh,
+				invID:           invID,
+				exonerated:      false,
+				status:          internal.VerdictStatus_VERDICT_FLAKY,
+				unexpectedCount: 1,
+				totalCount:      2,
+			},
+			"ninja://test_consistent_failure": {
+				realm:           realm,
+				testID:          "ninja://test_consistent_failure",
+				variantHash:     vh,
+				invID:           invID,
+				exonerated:      true,
+				status:          internal.VerdictStatus_UNEXPECTED,
+				unexpectedCount: 1,
+				totalCount:      1,
+			},
+			"ninja://test_has_unexpected": {
+				realm:           realm,
+				testID:          "ninja://test_has_unexpected",
+				variantHash:     vh,
+				invID:           invID,
+				exonerated:      false,
+				status:          internal.VerdictStatus_EXPECTED,
+				unexpectedCount: 0,
+				totalCount:      1,
+			},
+		}
+
+		fields := []string{"Realm", "TestId", "VariantHash", "InvocationId", "Exonerated", "Status", "UnexpectedResultCount", "TotalResultCount"}
+		total := 0
+		var b spanutil.Buffer
+		err = span.Read(ctx, "Verdicts", ks, fields).Do(
+			func(row *spanner.Row) error {
+				var v verdict
+				err = b.FromSpanner(row, &v.realm, &v.testID, &v.variantHash, &v.invID, &v.exonerated, &v.status, &v.unexpectedCount, &v.totalCount)
+				So(err, ShouldBeNil)
+				total++
+
+				exp, ok := expected[v.testID]
+				So(ok, ShouldBeTrue)
+				So(v, ShouldResemble, exp)
+				return nil
+			},
+		)
+		So(err, ShouldBeNil)
+		So(total, ShouldEqual, 3)
+
+	})
+}
diff --git a/analysis/internal/services/resultcollector/main_test.go b/analysis/internal/services/resultcollector/main_test.go
new file mode 100644
index 0000000..a802f25
--- /dev/null
+++ b/analysis/internal/services/resultcollector/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultcollector
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/services/resultcollector/test_data.go b/analysis/internal/services/resultcollector/test_data.go
new file mode 100644
index 0000000..0b9706f
--- /dev/null
+++ b/analysis/internal/services/resultcollector/test_data.go
@@ -0,0 +1,72 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultcollector
+
+import (
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+)
+
+func mockedBatchGetTestVariantsResponse() *rdbpb.BatchGetTestVariantsResponse {
+	return &rdbpb.BatchGetTestVariantsResponse{
+		TestVariants: []*rdbpb.TestVariant{
+			{
+				TestId:      "ninja://test_known_flake",
+				VariantHash: "variant_hash",
+				Status:      rdbpb.TestVariantStatus_FLAKY,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Status: rdbpb.TestStatus_SKIP,
+						},
+					},
+					{
+						Result: &rdbpb.TestResult{
+							Status: rdbpb.TestStatus_FAIL,
+						},
+					},
+					{
+						Result: &rdbpb.TestResult{
+							Status: rdbpb.TestStatus_PASS,
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_consistent_failure",
+				VariantHash: "variant_hash",
+				Status:      rdbpb.TestVariantStatus_EXONERATED,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Status: rdbpb.TestStatus_FAIL,
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_has_unexpected",
+				VariantHash: "variant_hash",
+				Status:      rdbpb.TestVariantStatus_EXPECTED,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Status: rdbpb.TestStatus_PASS,
+						},
+					},
+				},
+			},
+		},
+	}
+}
diff --git a/analysis/internal/services/resultcollector/verdicts.go b/analysis/internal/services/resultcollector/verdicts.go
new file mode 100644
index 0000000..fb4acd5
--- /dev/null
+++ b/analysis/internal/services/resultcollector/verdicts.go
@@ -0,0 +1,112 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultcollector
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/resultdb/pbutil"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+)
+
+func createVerdicts(ctx context.Context, task *taskspb.CollectTestResults, tvs []*rdbpb.TestVariant) error {
+	ms := make([]*spanner.Mutation, 0, len(tvs))
+	// Each batch of verdicts use the same ingestion time.
+	now := clock.Now(ctx)
+	for _, tv := range tvs {
+		if tv.Status == rdbpb.TestVariantStatus_UNEXPECTEDLY_SKIPPED {
+			continue
+		}
+		m := insertVerdict(task, tv, now)
+		if m == nil {
+			continue
+		}
+		ms = append(ms, m)
+	}
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		span.BufferWrite(ctx, ms...)
+		return nil
+	})
+	return err
+}
+
+func insertVerdict(task *taskspb.CollectTestResults, tv *rdbpb.TestVariant, ingestionTime time.Time) *spanner.Mutation {
+	inv := task.Resultdb.Invocation
+	invId, err := pbutil.ParseInvocationName(inv.Name)
+	if err != nil {
+		// This should never happen:inv was originally from ResultDB.
+		panic(err)
+	}
+	row := map[string]interface{}{
+		"Realm":                        inv.Realm,
+		"InvocationId":                 invId,
+		"InvocationCreationTime":       inv.CreateTime,
+		"IngestionTime":                ingestionTime,
+		"TestId":                       tv.TestId,
+		"VariantHash":                  tv.VariantHash,
+		"Status":                       deriveVerdictStatus(tv),
+		"Exonerated":                   tv.Status == rdbpb.TestVariantStatus_EXONERATED,
+		"IsPreSubmit":                  task.IsPreSubmit,
+		"HasContributedToClSubmission": task.ContributedToClSubmission,
+	}
+	row["UnexpectedResultCount"], row["TotalResultCount"] = countResults(tv)
+
+	if row["TotalResultCount"] == 0 {
+		// No results in the verdict can be counted (skips?), so no need to save
+		// this verdict.
+		return nil
+	}
+	return spanner.InsertOrUpdateMap("Verdicts", spanutil.ToSpannerMap(row))
+}
+
+func deriveVerdictStatus(tv *rdbpb.TestVariant) internal.VerdictStatus {
+	switch tv.Status {
+	case rdbpb.TestVariantStatus_FLAKY:
+		return internal.VerdictStatus_VERDICT_FLAKY
+	case rdbpb.TestVariantStatus_EXPECTED:
+		return internal.VerdictStatus_EXPECTED
+	case rdbpb.TestVariantStatus_UNEXPECTED:
+		return internal.VerdictStatus_UNEXPECTED
+	case rdbpb.TestVariantStatus_EXONERATED:
+		return internal.VerdictStatus_UNEXPECTED
+	default:
+		panic(fmt.Sprintf("impossible verdict status: %d", tv.Status))
+	}
+}
+
+func countResults(tv *rdbpb.TestVariant) (unexpected, total int64) {
+	for _, trb := range tv.Results {
+		tr := trb.Result
+		if tr.Status == rdbpb.TestStatus_SKIP {
+			// Skips are not counted into total nor unexpected.
+			continue
+		}
+		total++
+		if !tr.Expected && tr.Status != rdbpb.TestStatus_PASS {
+			// Count unexpected failures.
+			unexpected++
+		}
+	}
+	return
+}
diff --git a/analysis/internal/services/resultingester/analyzedtestvariants.go b/analysis/internal/services/resultingester/analyzedtestvariants.go
new file mode 100644
index 0000000..a1565ca
--- /dev/null
+++ b/analysis/internal/services/resultingester/analyzedtestvariants.go
@@ -0,0 +1,382 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultingester
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/trace"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/proto"
+
+	"go.chromium.org/luci/analysis/internal/analyzedtestvariants"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/services/testvariantupdator"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+type testVariantKey struct {
+	TestId      string
+	VariantHash string
+}
+
+// tagKeys are the keys for tags that should be saved with analyzed test
+// variants.
+var tagKeys = map[string]struct{}{
+	"monorail_component": {},
+	"os":                 {},
+	"team_email":         {},
+	"test_name":          {},
+	"target_platform":    {},
+}
+
+// shouldIngestForTestVariants returns whether the test results specified
+// by the given IngestTestResults task, with a realm with the given RealmConfig.
+func shouldIngestForTestVariants(realmcfg *configpb.RealmConfig, task *taskspb.IngestTestResults) bool {
+	if realmcfg.GetTestVariantAnalysis().GetUpdateTestVariantTask().GetUpdateTestVariantTaskInterval() == nil {
+		// Test Variant analysis not configured for realm. Skip ingestion.
+		return false
+	}
+	// Ingest results from CI.
+	return task.PresubmitRun == nil ||
+		// And presubmit results, where the presubmit run succeeded
+		// and the run was a FULL_RUN.
+		(task.PresubmitRun != nil &&
+			task.PresubmitRun.Status == pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED &&
+			task.PresubmitRun.Mode == pb.PresubmitRunMode_FULL_RUN)
+}
+
+// createOrUpdateAnalyzedTestVariants looks for new analyzed test variants or
+// the ones to be updated, and save them in Spanner.
+func createOrUpdateAnalyzedTestVariants(ctx context.Context, realm, builder string, tvs []*rdbpb.TestVariant) (err error) {
+	if len(tvs) == 0 {
+		return nil
+	}
+
+	ctx, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/services/resultingester.createOrUpdateAnalyzedTestVariants")
+	defer func() { s.End(err) }()
+
+	rc, err := config.Realm(ctx, realm)
+	switch {
+	case err != nil:
+		return err
+	case rc.GetTestVariantAnalysis().GetUpdateTestVariantTask().GetUpdateTestVariantTaskInterval() == nil:
+		// This should never occur, as shouldIngestForTestVariants() should be called
+		// before this method.
+		return fmt.Errorf("no UpdateTestVariantTask config found for realm %s", realm)
+	}
+
+	// The number of test variants to update at once.
+	const pageSize = 1000
+
+	// Process test variants in pages, to avoid exceeding Spanner mutation limits.
+	for page := 0; ; page++ {
+		pageStart := page * pageSize
+		pageEnd := (page + 1) * pageSize
+		if pageStart >= len(tvs) {
+			break
+		}
+		if pageEnd > len(tvs) {
+			pageEnd = len(tvs)
+		}
+		page := tvs[pageStart:pageEnd]
+		err := createOrUpdateAnalyzedTestVariantsPage(ctx, realm, builder, rc, page)
+		if err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// createOrUpdateAnalyzedTestVariantsPage looks for new analyzed test variants
+// or the ones to be updated, and save them in Spanner. At most 2,000 test
+// variants can be processed in one call due to Spanner mutation limits.
+func createOrUpdateAnalyzedTestVariantsPage(ctx context.Context, realm, builder string, rc *configpb.RealmConfig, tvs []*rdbpb.TestVariant) error {
+	for _, tv := range tvs {
+		if !hasUnexpectedFailures(tv) {
+			panic("logic error: createOrUpdateAnalyzedTestVariants should only be called with interesting test variants")
+		}
+	}
+
+	ks := testVariantKeySet(realm, tvs)
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		found := make(map[testVariantKey]*atvpb.AnalyzedTestVariant)
+		err := analyzedtestvariants.ReadStatusAndTags(ctx, ks, func(atv *atvpb.AnalyzedTestVariant) error {
+			k := testVariantKey{atv.TestId, atv.VariantHash}
+			found[k] = atv
+			return nil
+		})
+		if err != nil {
+			return err
+		}
+
+		ms := make([]*spanner.Mutation, 0)
+		// A map of test variants to the enqueue time of their first UpdateTestVariant
+		// task.
+		tvToEnQTime := make(map[testVariantKey]time.Time)
+		for _, tv := range tvs {
+			tvStr := fmt.Sprintf("%s-%s-%s", realm, tv.TestId, tv.VariantHash)
+
+			k := testVariantKey{tv.TestId, tv.VariantHash}
+			atv, ok := found[k]
+			if !ok {
+				m, enqueueTime, err := insertRow(ctx, realm, builder, tv)
+				if err != nil {
+					logging.Errorf(ctx, "Insert test variant %s: %s", tvStr, err)
+					continue
+				}
+				ms = append(ms, m)
+				tvToEnQTime[k] = enqueueTime
+			} else {
+				nts := updatedTags(extractRequiredTags(tv), atv.Tags)
+				ds, err := derivedStatus(tv.Status)
+				if err != nil {
+					logging.Errorf(ctx, "Update test variant %s: %s", tvStr, err)
+					continue
+				}
+				ns, err := updatedStatus(ds, atv.Status)
+				if err != nil {
+					logging.Errorf(ctx, "Update test variant %s: %s", tvStr, err)
+					continue
+				}
+				if ns == atv.Status && len(nts) == 0 {
+					continue
+				}
+
+				vals := map[string]interface{}{
+					"Realm":       atv.Realm,
+					"TestId":      atv.TestId,
+					"VariantHash": atv.VariantHash,
+				}
+				if len(nts) > 0 {
+					vals["Tags"] = nts
+				}
+				if ns != atv.Status {
+					vals["Status"] = int64(ns)
+					if atv.Status == atvpb.Status_CONSISTENTLY_EXPECTED || atv.Status == atvpb.Status_NO_NEW_RESULTS {
+						// The test variant starts to have unexpected failures again, need
+						// to start updating its status.
+						now := clock.Now(ctx)
+						vals["NextUpdateTaskEnqueueTime"] = now
+						tvToEnQTime[k] = now
+					}
+				}
+				ms = append(ms, spanutil.UpdateMap("AnalyzedTestVariants", vals))
+			}
+		}
+		span.BufferWrite(ctx, ms...)
+		for tvKey, enQTime := range tvToEnQTime {
+			testvariantupdator.Schedule(ctx, realm, tvKey.TestId, tvKey.VariantHash, rc.TestVariantAnalysis.UpdateTestVariantTask.UpdateTestVariantTaskInterval, enQTime)
+		}
+		return nil
+	})
+	return err
+}
+
+func testVariantKeySet(realm string, tvs []*rdbpb.TestVariant) spanner.KeySet {
+	keys := make([]spanner.Key, 0, len(tvs))
+	for _, tv := range tvs {
+		keys = append(keys, spanner.Key{realm, tv.TestId, tv.VariantHash})
+	}
+	return spanner.KeySetFromKeys(keys...)
+}
+
+func hasUnexpectedFailures(tv *rdbpb.TestVariant) bool {
+	if tv.Status == rdbpb.TestVariantStatus_UNEXPECTEDLY_SKIPPED ||
+		tv.Status == rdbpb.TestVariantStatus_EXPECTED {
+		return false
+	}
+
+	for _, trb := range tv.Results {
+		tr := trb.Result
+		if !tr.Expected && tr.Status != rdbpb.TestStatus_PASS && tr.Status != rdbpb.TestStatus_SKIP {
+			// If any result is an unexpected failure, Weetbix should save this test variant.
+			return true
+		}
+	}
+	return false
+}
+
+func insertRow(ctx context.Context, realm, builder string, tv *rdbpb.TestVariant) (mu *spanner.Mutation, enqueueTime time.Time, err error) {
+	status, err := derivedStatus(tv.Status)
+	if err != nil {
+		return nil, time.Time{}, err
+	}
+
+	now := clock.Now(ctx)
+	row := map[string]interface{}{
+		"Realm":                     realm,
+		"TestId":                    tv.TestId,
+		"VariantHash":               tv.VariantHash,
+		"Variant":                   pbutil.VariantFromResultDB(tv.Variant),
+		"Status":                    int64(status),
+		"CreateTime":                spanner.CommitTimestamp,
+		"StatusUpdateTime":          spanner.CommitTimestamp,
+		"Builder":                   builder,
+		"Tags":                      extractRequiredTags(tv),
+		"NextUpdateTaskEnqueueTime": now,
+	}
+	if tv.TestMetadata != nil {
+		tmd, err := proto.Marshal(pbutil.TestMetadataFromResultDB(tv.TestMetadata))
+		if err != nil {
+			panic(fmt.Sprintf("failed to marshal TestMetadata to bytes: %q", err))
+		}
+		row["TestMetadata"] = spanutil.Compressed(tmd)
+	}
+
+	return spanutil.InsertMap("AnalyzedTestVariants", row), now, nil
+}
+
+func derivedStatus(tvStatus rdbpb.TestVariantStatus) (atvpb.Status, error) {
+	switch {
+	case tvStatus == rdbpb.TestVariantStatus_FLAKY:
+		// The new test variant has flaky results in a build, the analyzed test
+		// variant becomes flaky.
+		// Note that this is only true if Weetbix knows the the ingested test
+		// results are from builds contribute to CL submissions. Which is true for
+		// Chromium, the only project Weetbix supports now.
+		return atvpb.Status_FLAKY, nil
+	case tvStatus == rdbpb.TestVariantStatus_UNEXPECTED || tvStatus == rdbpb.TestVariantStatus_EXONERATED:
+		return atvpb.Status_HAS_UNEXPECTED_RESULTS, nil
+	default:
+		return atvpb.Status_STATUS_UNSPECIFIED, fmt.Errorf("unsupported test variant status: %s", tvStatus.String())
+	}
+}
+
+// Get the updated AnalyzedTestVariant status based on the ResultDB test variant
+// status.
+func updatedStatus(derived, old atvpb.Status) (atvpb.Status, error) {
+	switch {
+	case old == derived:
+		return old, nil
+	case old == atvpb.Status_FLAKY:
+		// If the AnalyzedTestVariant is already Flaky, its status does not change here.
+		return old, nil
+	case derived == atvpb.Status_FLAKY:
+		// Any flaky occurrence will make an AnalyzedTestVariant become flaky.
+		return derived, nil
+	case old == atvpb.Status_CONSISTENTLY_UNEXPECTED:
+		// All results of the ResultDB test variant are unexpected, so AnalyzedTestVariant
+		// does need to change status.
+		return old, nil
+	case old == atvpb.Status_CONSISTENTLY_EXPECTED || old == atvpb.Status_NO_NEW_RESULTS:
+		// New failures are found, AnalyzedTestVariant needs to change status.
+		return derived, nil
+	default:
+		return atvpb.Status_STATUS_UNSPECIFIED, fmt.Errorf("unsupported updated Status")
+	}
+}
+
+func extractRequiredTags(tv *rdbpb.TestVariant) []*pb.StringPair {
+	tags := make([]*pb.StringPair, 0)
+	knownKeys := make(map[string]struct{})
+	for _, tr := range tv.Results {
+		for _, t := range tr.Result.GetTags() {
+			if _, ok := tagKeys[t.Key]; !ok {
+				// We don't care about this tag.
+				continue
+			}
+			if _, ok := knownKeys[t.Key]; ok {
+				// We've got this tag.
+				continue
+			}
+			knownKeys[t.Key] = struct{}{}
+			tags = append(tags, &pb.StringPair{
+				Key:   t.Key,
+				Value: t.Value,
+			})
+		}
+	}
+
+	// Ensure determinism by leaving tags in sorted order.
+	sortTags(tags)
+	return tags
+}
+
+// tagsEqual compares two sets of tags.
+func tagsEqual(newTags, oldTags []*pb.StringPair) bool {
+	if len(newTags) != len(oldTags) {
+		return false
+	}
+	ntStrings := pbutil.StringPairsToStrings(newTags...)
+	sort.Strings(ntStrings)
+	otStrings := pbutil.StringPairsToStrings(oldTags...)
+	sort.Strings(otStrings)
+
+	for i, t := range ntStrings {
+		if t != otStrings[i] {
+			return false
+		}
+	}
+	return true
+}
+
+// updatedTags returns a merged slices of tags.
+// * if the same key appears in both newTags and oldTags, use the value from
+//   newTags;
+// * if a key appears in only one of the slices, append the string pair as it
+//   is;
+// * if the merged slice is the same as oldTags, return nil to indicate there
+//   is no need to update tags.
+func updatedTags(newTags, oldTags []*pb.StringPair) []*pb.StringPair {
+	switch {
+	case len(newTags) == 0:
+		return nil
+	case len(oldTags) == 0:
+		return newTags
+	}
+
+	if same := tagsEqual(newTags, oldTags); same {
+		return nil
+	}
+
+	resultMap := make(map[string]*pb.StringPair)
+	for _, t := range oldTags {
+		resultMap[t.Key] = t
+	}
+	for _, t := range newTags {
+		resultMap[t.Key] = t
+	}
+
+	result := make([]*pb.StringPair, 0, len(resultMap))
+	for _, t := range resultMap {
+		result = append(result, t)
+	}
+
+	// Ensure determinism by leaving tags in sorted order.
+	// This is primarily to avoid flakes in unit tests.
+	sortTags(result)
+	return result
+}
+
+// sortTags performs an in-place sort of tags to be in
+// ascending key order.
+func sortTags(tags []*pb.StringPair) {
+	sort.Slice(tags, func(i, j int) bool {
+		return tags[i].Key < tags[j].Key
+	})
+}
diff --git a/analysis/internal/services/resultingester/ingest_test_results.go b/analysis/internal/services/resultingester/ingest_test_results.go
new file mode 100644
index 0000000..282a279
--- /dev/null
+++ b/analysis/internal/services/resultingester/ingest_test_results.go
@@ -0,0 +1,556 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultingester
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"time"
+
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/retry/transient"
+	"go.chromium.org/luci/common/trace"
+	"go.chromium.org/luci/common/tsmon/field"
+	"go.chromium.org/luci/common/tsmon/metric"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+	"golang.org/x/sync/semaphore"
+	"google.golang.org/genproto/protobuf/field_mask"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/analysis/clusteredfailures"
+	"go.chromium.org/luci/analysis/internal/buildbucket"
+	"go.chromium.org/luci/analysis/internal/clustering/chunkstore"
+	"go.chromium.org/luci/analysis/internal/clustering/ingestion"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+	"go.chromium.org/luci/analysis/internal/resultdb"
+	"go.chromium.org/luci/analysis/internal/services/resultcollector"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testresults"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const (
+	resultIngestionTaskClass = "result-ingestion"
+	resultIngestionQueue     = "result-ingestion"
+
+	// ingestionEarliest is the oldest data that may be ingested by Weetbix.
+	// This is an offset relative to the current time, and should be kept
+	// in sync with the data retention period in Spanner and BigQuery.
+	ingestionEarliest = -90 * 24 * time.Hour
+
+	// ingestionLatest is the newest data that may be ingested by Weetbix.
+	// This is an offset relative to the current time. It is designed to
+	// allow for clock drift.
+	ingestionLatest = 24 * time.Hour
+)
+
+var (
+	taskCounter = metric.NewCounter(
+		"weetbix/ingestion/task_completion",
+		"The number of completed Weetbix ingestion tasks, by build project and outcome.",
+		nil,
+		// The LUCI Project.
+		field.String("project"),
+		// "success", "failed_validation",
+		// "ignored_no_bb_access", "ignored_no_project_config",
+		// "ignored_no_invocation", "ignored_has_ancestor".
+		field.String("outcome"))
+
+	ancestorCounter = metric.NewCounter(
+		"weetbix/ingestion/ancestor_build_status",
+		"The status retrieving ancestor builds in ingestion tasks, by build project.",
+		nil,
+		// The LUCI Project.
+		field.String("project"),
+		// "no_bb_access_to_ancestor",
+		// "no_resultdb_invocation_on_ancestor",
+		// "ok".
+		field.String("ancestor_status"))
+
+	testVariantReadMask = &fieldmaskpb.FieldMask{
+		Paths: []string{
+			"test_id",
+			"variant_hash",
+			"status",
+			"variant",
+			"test_metadata",
+			"exonerations.*.reason",
+			"results.*.result.name",
+			"results.*.result.expected",
+			"results.*.result.status",
+			"results.*.result.start_time",
+			"results.*.result.duration",
+			"results.*.result.tags",
+			"results.*.result.failure_reason",
+		},
+	}
+
+	buildReadMask = &field_mask.FieldMask{
+		Paths: []string{"builder", "infra.resultdb", "status", "input", "output", "ancestor_ids"},
+	}
+
+	// chromiumMilestoneProjectPrefix is the LUCI project prefix
+	// of chromium milestone projects, e.g. chromium-m100.
+	chromiumMilestoneProjectRE = regexp.MustCompile(`^(chrome|chromium)-m[0-9]+$`)
+)
+
+// Options configures test result ingestion.
+type Options struct {
+}
+
+type resultIngester struct {
+	clustering *ingestion.Ingester
+}
+
+var resultIngestion = tq.RegisterTaskClass(tq.TaskClass{
+	ID:        resultIngestionTaskClass,
+	Prototype: &taskspb.IngestTestResults{},
+	Queue:     resultIngestionQueue,
+	Kind:      tq.Transactional,
+})
+
+// RegisterTaskHandler registers the handler for result ingestion tasks.
+func RegisterTaskHandler(srv *server.Server) error {
+	ctx := srv.Context
+	cfg, err := config.Get(ctx)
+	if err != nil {
+		return err
+	}
+	chunkStore, err := chunkstore.NewClient(ctx, cfg.ChunkGcsBucket)
+	if err != nil {
+		return err
+	}
+	srv.RegisterCleanup(func(context.Context) {
+		chunkStore.Close()
+	})
+
+	cf, err := clusteredfailures.NewClient(ctx, srv.Options.CloudProject)
+	if err != nil {
+		return err
+	}
+	srv.RegisterCleanup(func(context.Context) {
+		cf.Close()
+	})
+
+	analysis := analysis.NewClusteringHandler(cf)
+	ri := &resultIngester{
+		clustering: ingestion.New(chunkStore, analysis),
+	}
+	handler := func(ctx context.Context, payload proto.Message) error {
+		task := payload.(*taskspb.IngestTestResults)
+		return ri.ingestTestResults(ctx, task)
+	}
+	resultIngestion.AttachHandler(handler)
+	return nil
+}
+
+// Schedule enqueues a task to ingest test results from a build.
+func Schedule(ctx context.Context, task *taskspb.IngestTestResults) {
+	tq.MustAddTask(ctx, &tq.Task{
+		Title:   fmt.Sprintf("%s-%s-%d-page-%v", task.Build.Project, task.Build.Host, task.Build.Id, task.TaskIndex),
+		Payload: task,
+	})
+}
+
+// requestLimiter limits the number of concurrent result ingestion requests.
+// This is to ensure the instance remains within GAE memory limits.
+// These requests are larger than others and latency is not critical,
+// so using a semaphore to limit throughput was deemed overall better than
+// limiting the number of concurrent requests to the instance as a whole.
+var requestLimiter = semaphore.NewWeighted(5)
+
+func (i *resultIngester) ingestTestResults(ctx context.Context, payload *taskspb.IngestTestResults) error {
+	if err := validateRequest(ctx, payload); err != nil {
+		project := "(unknown)"
+		if payload.GetBuild().GetProject() != "" {
+			project = payload.Build.Project
+		}
+		taskCounter.Add(ctx, 1, project, "failed_validation")
+		return tq.Fatal.Apply(err)
+	}
+
+	// Limit the number of concurrent requests in the following section.
+	err := requestLimiter.Acquire(ctx, 1)
+	if err != nil {
+		return transient.Tag.Apply(err)
+	}
+	defer requestLimiter.Release(1)
+
+	// Buildbucket build only has builder, infra.resultdb, status populated.
+	build, err := retrieveBuild(ctx, payload.Build.Host, payload.Build.Id)
+	code := status.Code(err)
+	if code == codes.NotFound {
+		// Build not found, end the task gracefully.
+		logging.Warningf(ctx, "Buildbucket build %s/%d for project %s not found (or Weetbix does not have access to read it).",
+			payload.Build.Host, payload.Build.Id, payload.Build.Project)
+		taskCounter.Add(ctx, 1, payload.Build.Project, "ignored_no_bb_access")
+		return nil
+	}
+	if err != nil {
+		return transient.Tag.Apply(err)
+	}
+
+	if build.Infra.GetResultdb().GetInvocation() == "" {
+		// Build does not have a ResultDB invocation to ingest.
+		logging.Debugf(ctx, "Skipping ingestion of build %s-%d because it has no ResultDB invocation.",
+			payload.Build.Host, payload.Build.Id)
+		taskCounter.Add(ctx, 1, payload.Build.Project, "ignored_no_invocation")
+		return nil
+	}
+
+	if payload.TaskIndex == 0 {
+		// Before ingesting any of the build. If we are already ingesting the
+		// build (TaskIndex > 0), we made it past this check before.
+		if len(build.AncestorIds) > 0 {
+			// If the build has an ancestor build, see if its immediate
+			// ancestor is accessible by Weetbix and has a ResultDB invocation
+			// (likely indicating it includes the test results from this
+			// build).
+			included, err := includedByAncestorBuild(ctx, payload.Build.Host, build.AncestorIds[len(build.AncestorIds)-1], payload.Build.Project)
+			if err != nil {
+				return transient.Tag.Apply(err)
+			}
+			if included {
+				// Yes. Do not ingest this build to avoid ingesting the same test
+				// results multiple times.
+				taskCounter.Add(ctx, 1, payload.Build.Project, "ignored_has_ancestor")
+				return nil
+			}
+		}
+	}
+
+	rdbHost := build.Infra.Resultdb.Hostname
+	invName := build.Infra.Resultdb.Invocation
+	builder := build.Builder.Builder
+	rc, err := resultdb.NewClient(ctx, rdbHost)
+	if err != nil {
+		return transient.Tag.Apply(err)
+	}
+	inv, err := rc.GetInvocation(ctx, invName)
+	code = status.Code(err)
+	if code == codes.NotFound {
+		// Invocation not found, end the task gracefully.
+		logging.Warningf(ctx, "Invocation %s for project %s not found (or Weetbix does not have access to read it).",
+			invName, payload.Build.Project)
+		taskCounter.Add(ctx, 1, payload.Build.Project, "ignored_no_resultdb_access")
+		return nil
+	}
+	if err != nil {
+		return transient.Tag.Apply(err)
+	}
+
+	ingestedInv, gitRef, err := extractIngestionContext(payload, build, inv)
+	if err != nil {
+		return err
+	}
+
+	if payload.TaskIndex == 0 {
+		// The first task should create the ingested invocation record
+		// and git reference record referenced from the invocation record
+		// (if any).
+		err = recordIngestionContext(ctx, ingestedInv, gitRef)
+		if err != nil {
+			return err
+		}
+	}
+
+	// Query test variants from ResultDB.
+	req := &rdbpb.QueryTestVariantsRequest{
+		Invocations: []string{inv.Name},
+		PageSize:    10000,
+		ReadMask:    testVariantReadMask,
+		PageToken:   payload.PageToken,
+	}
+	rsp, err := rc.QueryTestVariants(ctx, req)
+	if err != nil {
+		err = errors.Annotate(err, "query test variants").Err()
+		return transient.Tag.Apply(err)
+	}
+
+	// Schedule a task to deal with the next page of results (if needed).
+	// Do this immediately, so that task can commence while we are still
+	// inserting the results for this page.
+	if rsp.NextPageToken != "" {
+		if err := scheduleNextTask(ctx, payload, rsp.NextPageToken); err != nil {
+			err = errors.Annotate(err, "schedule next task").Err()
+			return transient.Tag.Apply(err)
+		}
+	}
+
+	// Record the test results for test history.
+	err = recordTestResults(ctx, ingestedInv, rsp.TestVariants)
+	if err != nil {
+		// If any transaction failed, the task will be retried and the tables will be
+		// eventual-consistent.
+		return errors.Annotate(err, "record test results").Err()
+	}
+
+	// Clustering and test variant analysis currently don't support chromium
+	// milestone projects.
+	if chromiumMilestoneProjectRE.MatchString(payload.Build.Project) {
+		return nil
+	}
+
+	failingTVs := filterToTestVariantsWithUnexpectedFailures(rsp.TestVariants)
+	nextPageToken := rsp.NextPageToken
+	// Allow garbage collector to free test variants except for those that are
+	// unexpected.
+	rsp = nil
+
+	// Insert the test results for clustering.
+	err = ingestForClustering(ctx, i.clustering, payload, ingestedInv, failingTVs)
+	if err != nil {
+		return err
+	}
+
+	// Ingest for test variant analysis.
+	realmCfg, err := config.Realm(ctx, inv.Realm)
+	if err != nil && err != config.RealmNotExistsErr {
+		return transient.Tag.Apply(err)
+	}
+
+	ingestForTestVariantAnalysis := realmCfg != nil &&
+		shouldIngestForTestVariants(realmCfg, payload)
+
+	if ingestForTestVariantAnalysis {
+		if err := createOrUpdateAnalyzedTestVariants(ctx, inv.Realm, builder, failingTVs); err != nil {
+			err = errors.Annotate(err, "ingesting for test variant analysis").Err()
+			return transient.Tag.Apply(err)
+		}
+
+		if nextPageToken == "" {
+			// In the last task, after all test variants ingested.
+			isPreSubmit := payload.PresubmitRun != nil
+			contributedToCLSubmission := payload.PresubmitRun != nil &&
+				payload.PresubmitRun.Mode == pb.PresubmitRunMode_FULL_RUN &&
+				payload.PresubmitRun.Status == pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED
+			if err = resultcollector.Schedule(ctx, inv, rdbHost, build.Builder.Builder, isPreSubmit, contributedToCLSubmission); err != nil {
+				return transient.Tag.Apply(err)
+			}
+		}
+	}
+
+	if nextPageToken == "" {
+		// In the last task.
+		taskCounter.Add(ctx, 1, payload.Build.Project, "success")
+	}
+	return nil
+}
+
+func includedByAncestorBuild(ctx context.Context, buildHost string, buildID int64, project string) (bool, error) {
+	// Retrieve the ancestor build.
+	rootBuild, err := retrieveBuild(ctx, buildHost, buildID)
+	code := status.Code(err)
+	if code == codes.NotFound {
+		logging.Warningf(ctx, "Buildbucket ancestor build %s/%d for project %s not found (or Weetbix does not have access to read it).",
+			buildHost, buildID, project)
+		// Weetbix won't be able to retrieve the ancestor build to ingest it,
+		// even if it did include the test results from this build.
+
+		ancestorCounter.Add(ctx, 1, project, "no_bb_access_to_ancestor")
+		return false, nil
+	}
+	if err != nil {
+		return false, errors.Annotate(err, "retrieving ancestor build").Err()
+	}
+	if rootBuild.Infra.GetResultdb().GetInvocation() == "" {
+		ancestorCounter.Add(ctx, 1, project, "no_resultdb_invocation_on_ancestor")
+		return false, nil
+	}
+
+	// The ancestor build also has a ResultDB invocation. This is what
+	// we expected. We will ingest the ancestor build only
+	// to avoid ingesting the same test results multiple times.
+	ancestorCounter.Add(ctx, 1, project, "ok")
+	return true, nil
+}
+
+// filterToTestVariantsWithUnexpectedFailures filters the given list of
+// test variants to only those with unexpected failures.
+func filterToTestVariantsWithUnexpectedFailures(tvs []*rdbpb.TestVariant) []*rdbpb.TestVariant {
+	var results []*rdbpb.TestVariant
+	for _, tv := range tvs {
+		if hasUnexpectedFailures(tv) {
+			results = append(results, tv)
+		}
+	}
+	return results
+}
+
+// scheduleNextTask schedules a task to continue the ingestion,
+// starting at the given page token.
+// If a continuation task for this task has been previously scheduled
+// (e.g. in a previous try of this task), this method does nothing.
+func scheduleNextTask(ctx context.Context, task *taskspb.IngestTestResults, nextPageToken string) error {
+	if nextPageToken == "" {
+		// If the next page token is "", it means ResultDB returned the
+		// last page. We should not schedule a continuation task.
+		panic("next page token cannot be the empty page token")
+	}
+	buildID := control.BuildID(task.Build.Host, task.Build.Id)
+
+	// Schedule the task transactionally, conditioned on it not having been
+	// scheduled before.
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		entries, err := control.Read(ctx, []string{buildID})
+		if err != nil {
+			return errors.Annotate(err, "read ingestion record").Err()
+		}
+
+		entry := entries[0]
+		if entry == nil {
+			return errors.Reason("build %v does not have ingestion record", buildID).Err()
+		}
+		if task.TaskIndex >= entry.TaskCount {
+			// This should nver happen.
+			panic("current ingestion task not recorded on ingestion control record")
+		}
+		nextTaskIndex := task.TaskIndex + 1
+		if nextTaskIndex != entry.TaskCount {
+			// Next task has already been created in the past. Do not create
+			// it again.
+			// This can happen if the ingestion task failed after
+			// it scheduled the ingestion task for the next page,
+			// and was subsequently retried.
+			return nil
+		}
+		entry.TaskCount = entry.TaskCount + 1
+		if err := control.InsertOrUpdate(ctx, entry); err != nil {
+			return errors.Annotate(err, "update ingestion record").Err()
+		}
+
+		itrTask := &taskspb.IngestTestResults{
+			PartitionTime: task.PartitionTime,
+			Build:         task.Build,
+			PresubmitRun:  task.PresubmitRun,
+			PageToken:     nextPageToken,
+			TaskIndex:     nextTaskIndex,
+		}
+		Schedule(ctx, itrTask)
+
+		return nil
+	})
+	return err
+}
+
+func ingestForClustering(ctx context.Context, clustering *ingestion.Ingester, payload *taskspb.IngestTestResults, inv *testresults.IngestedInvocation, tvs []*rdbpb.TestVariant) (err error) {
+	ctx, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/services/resultingester.ingestForClustering")
+	defer func() { s.End(err) }()
+
+	if _, err := config.Project(ctx, payload.Build.Project); err != nil {
+		if err == config.NotExistsErr {
+			// Project not configured in Weetbix, ignore it.
+			return nil
+		} else {
+			// Transient error.
+			return transient.Tag.Apply(errors.Annotate(err, "get project config").Err())
+		}
+	}
+
+	changelists := make([]*pb.Changelist, 0, len(inv.Changelists))
+	for _, cl := range inv.Changelists {
+		changelists = append(changelists, &pb.Changelist{
+			Host:     cl.Host + "-review.googlesource.com",
+			Change:   cl.Change,
+			Patchset: int32(cl.Patchset),
+		})
+	}
+
+	// Setup clustering ingestion.
+	opts := ingestion.Options{
+		TaskIndex:     payload.TaskIndex,
+		Project:       inv.Project,
+		PartitionTime: inv.PartitionTime,
+		Realm:         inv.Project + ":" + inv.SubRealm,
+		InvocationID:  inv.IngestedInvocationID,
+		BuildStatus:   inv.BuildStatus,
+		Changelists:   changelists,
+	}
+
+	if payload.PresubmitRun != nil {
+		opts.PresubmitRun = &ingestion.PresubmitRun{
+			ID:     payload.PresubmitRun.PresubmitRunId,
+			Owner:  payload.PresubmitRun.Owner,
+			Mode:   payload.PresubmitRun.Mode,
+			Status: payload.PresubmitRun.Status,
+		}
+		opts.BuildCritical = payload.PresubmitRun.Critical
+		if payload.PresubmitRun.Critical && inv.BuildStatus == pb.BuildStatus_BUILD_STATUS_FAILURE &&
+			payload.PresubmitRun.Status == pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED {
+			logging.Warningf(ctx, "Inconsistent data from LUCI CV: build %v/%v was critical to presubmit run %v/%v and failed, but presubmit run succeeded.",
+				payload.Build.Host, payload.Build.Id, payload.PresubmitRun.PresubmitRunId.System, payload.PresubmitRun.PresubmitRunId.Id)
+		}
+	}
+	// Clustering ingestion is designed to behave gracefully in case of
+	// a task retry. Given the same options and same test variants (in
+	// the same order), the IDs and content of the chunks it writes is
+	// designed to be stable. If chunks already exist, it will skip them.
+	if err := clustering.Ingest(ctx, opts, tvs); err != nil {
+		err = errors.Annotate(err, "ingesting for clustering").Err()
+		return transient.Tag.Apply(err)
+	}
+	return nil
+}
+
+func validateRequest(ctx context.Context, payload *taskspb.IngestTestResults) error {
+	if !payload.PartitionTime.IsValid() {
+		return errors.New("partition time must be specified and valid")
+	}
+	t := payload.PartitionTime.AsTime()
+	now := clock.Now(ctx)
+	if t.Before(now.Add(ingestionEarliest)) {
+		return fmt.Errorf("partition time (%v) is too long ago", t)
+	} else if t.After(now.Add(ingestionLatest)) {
+		return fmt.Errorf("partition time (%v) is too far in the future", t)
+	}
+	if payload.Build == nil {
+		return errors.New("build must be specified")
+	}
+	if payload.Build.Project == "" {
+		return errors.New("project must be specified")
+	}
+	return nil
+}
+
+func retrieveBuild(ctx context.Context, bbHost string, id int64) (*bbpb.Build, error) {
+	bc, err := buildbucket.NewClient(ctx, bbHost)
+	if err != nil {
+		return nil, err
+	}
+	request := &bbpb.GetBuildRequest{
+		Id: id,
+		Mask: &bbpb.BuildMask{
+			Fields: buildReadMask,
+		},
+	}
+	b, err := bc.GetBuild(ctx, request)
+	switch {
+	case err != nil:
+		return nil, err
+	}
+	return b, nil
+}
diff --git a/analysis/internal/services/resultingester/ingest_test_results_test.go b/analysis/internal/services/resultingester/ingest_test_results_test.go
new file mode 100644
index 0000000..93629fb
--- /dev/null
+++ b/analysis/internal/services/resultingester/ingest_test_results_test.go
@@ -0,0 +1,1067 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultingester
+
+import (
+	"context"
+	"sort"
+	"strings"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"github.com/golang/mock/gomock"
+	. "github.com/smartystreets/goconvey/convey"
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/common/clock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	rdbpbutil "go.chromium.org/luci/resultdb/pbutil"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/caching"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+	_ "go.chromium.org/luci/server/tq/txn/spanner"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/durationpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/analysis/clusteredfailures"
+	"go.chromium.org/luci/analysis/internal/buildbucket"
+	"go.chromium.org/luci/analysis/internal/clustering/chunkstore"
+	"go.chromium.org/luci/analysis/internal/clustering/ingestion"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/ingestion/control"
+	ctrlpb "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	"go.chromium.org/luci/analysis/internal/resultdb"
+	"go.chromium.org/luci/analysis/internal/services/resultcollector"
+	"go.chromium.org/luci/analysis/internal/services/testvariantupdator"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testresults"
+	"go.chromium.org/luci/analysis/internal/testresults/gitreferences"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestSchedule(t *testing.T) {
+	Convey(`TestSchedule`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx, skdr := tq.TestingContext(ctx, nil)
+
+		task := &taskspb.IngestTestResults{
+			Build:         &ctrlpb.BuildResult{},
+			PartitionTime: timestamppb.New(time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC)),
+		}
+		expected := proto.Clone(task).(*taskspb.IngestTestResults)
+
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			Schedule(ctx, task)
+			return nil
+		})
+		So(err, ShouldBeNil)
+		So(skdr.Tasks().Payloads()[0], ShouldResembleProto, expected)
+	})
+}
+
+func TestShouldIngestForTestVariants(t *testing.T) {
+	t.Parallel()
+	Convey(`With realm config`, t, func() {
+		realm := &configpb.RealmConfig{
+			Name: "ci",
+			TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+				UpdateTestVariantTask: &configpb.UpdateTestVariantTask{
+					UpdateTestVariantTaskInterval:   durationpb.New(time.Hour),
+					TestVariantStatusUpdateDuration: durationpb.New(24 * time.Hour),
+				},
+			},
+		}
+		payload := &taskspb.IngestTestResults{
+			Build: &ctrlpb.BuildResult{
+				Host: "host",
+				Id:   int64(1),
+			},
+			PartitionTime: timestamppb.New(time.Date(2025, time.January, 1, 12, 0, 0, 0, time.UTC)),
+		}
+		Convey(`CI`, func() {
+			So(shouldIngestForTestVariants(realm, payload), ShouldBeTrue)
+		})
+		Convey(`CQ run`, func() {
+			payload.PresubmitRun = &ctrlpb.PresubmitResult{
+				PresubmitRunId: &pb.PresubmitRunId{
+					System: "luci-cv",
+					Id:     "chromium/1111111111111-1-1111111111111111",
+				},
+				Status: pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED,
+				Mode:   pb.PresubmitRunMode_FULL_RUN,
+			}
+			Convey(`Successful full run`, func() {
+				So(shouldIngestForTestVariants(realm, payload), ShouldBeTrue)
+			})
+			Convey(`Successful dry run`, func() {
+				payload.PresubmitRun.Mode = pb.PresubmitRunMode_DRY_RUN
+				So(shouldIngestForTestVariants(realm, payload), ShouldBeFalse)
+			})
+			Convey(`Failed run`, func() {
+				payload.PresubmitRun.Status = pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED
+				So(shouldIngestForTestVariants(realm, payload), ShouldBeFalse)
+			})
+		})
+		Convey(`Test Variant analysis not configured`, func() {
+			realm.TestVariantAnalysis = nil
+			So(shouldIngestForTestVariants(realm, payload), ShouldBeFalse)
+		})
+	})
+}
+
+func createProjectsConfig() map[string]*configpb.ProjectConfig {
+	return map[string]*configpb.ProjectConfig{
+		"project": {
+			Realms: []*configpb.RealmConfig{
+				{
+					Name: "ci",
+					TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+						UpdateTestVariantTask: &configpb.UpdateTestVariantTask{
+							UpdateTestVariantTaskInterval:   durationpb.New(time.Hour),
+							TestVariantStatusUpdateDuration: durationpb.New(24 * time.Hour),
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func TestIngestTestResults(t *testing.T) {
+	resultcollector.RegisterTaskClass()
+	testvariantupdator.RegisterTaskClass()
+
+	Convey(`TestIngestTestResults`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx = caching.WithEmptyProcessCache(ctx) // For failure association rules cache.
+		ctx, skdr := tq.TestingContext(ctx, nil)
+		ctx = memory.Use(ctx)
+
+		chunkStore := chunkstore.NewFakeClient()
+		clusteredFailures := clusteredfailures.NewFakeClient()
+		analysis := analysis.NewClusteringHandler(clusteredFailures)
+		ri := &resultIngester{
+			clustering: ingestion.New(chunkStore, analysis),
+		}
+
+		Convey(`partition time`, func() {
+			payload := &taskspb.IngestTestResults{
+				Build: &ctrlpb.BuildResult{
+					Host:    "host",
+					Id:      13131313,
+					Project: "project",
+				},
+				PartitionTime: timestamppb.New(clock.Now(ctx).Add(-1 * time.Hour)),
+			}
+			Convey(`too early`, func() {
+				payload.PartitionTime = timestamppb.New(clock.Now(ctx).Add(25 * time.Hour))
+				err := ri.ingestTestResults(ctx, payload)
+				So(err, ShouldErrLike, "too far in the future")
+			})
+			Convey(`too late`, func() {
+				payload.PartitionTime = timestamppb.New(clock.Now(ctx).Add(-91 * 24 * time.Hour))
+				err := ri.ingestTestResults(ctx, payload)
+				So(err, ShouldErrLike, "too long ago")
+			})
+		})
+
+		Convey(`valid payload`, func() {
+			config.SetTestProjectConfig(ctx, createProjectsConfig())
+
+			ctl := gomock.NewController(t)
+			defer ctl.Finish()
+
+			mrc := resultdb.NewMockedClient(ctx, ctl)
+			mbc := buildbucket.NewMockedClient(mrc.Ctx, ctl)
+			ctx = mbc.Ctx
+
+			bHost := "host"
+			bID := int64(87654321)
+			inv := "invocations/build-87654321"
+			realm := "project:ci"
+			partitionTime := clock.Now(ctx).Add(-1 * time.Hour)
+
+			expectedGitReference := &gitreferences.GitReference{
+				Project:          "project",
+				GitReferenceHash: gitreferences.GitReferenceHash("myproject.googlesource.com", "someproject/src", "refs/heads/mybranch"),
+				Hostname:         "myproject.googlesource.com",
+				Repository:       "someproject/src",
+				Reference:        "refs/heads/mybranch",
+			}
+
+			expectedInvocation := &testresults.IngestedInvocation{
+				Project:              "project",
+				IngestedInvocationID: "build-87654321",
+				SubRealm:             "ci",
+				PartitionTime:        timestamppb.New(partitionTime).AsTime(),
+				BuildStatus:          pb.BuildStatus_BUILD_STATUS_FAILURE,
+				PresubmitRun: &testresults.PresubmitRun{
+					Owner: "automation",
+					Mode:  pb.PresubmitRunMode_FULL_RUN,
+				},
+				GitReferenceHash: expectedGitReference.GitReferenceHash,
+				CommitPosition:   111888,
+				CommitHash:       strings.Repeat("0a", 20),
+				Changelists: []testresults.Changelist{
+					{
+						Host:     "anothergerrit",
+						Change:   77788,
+						Patchset: 19,
+					},
+					{
+						Host:     "mygerrit",
+						Change:   12345,
+						Patchset: 5,
+					},
+				},
+			}
+
+			verifyIngestedInvocation := func(expected *testresults.IngestedInvocation) {
+				var invs []*testresults.IngestedInvocation
+				// Validate IngestedInvocations table is populated.
+				err := testresults.ReadIngestedInvocations(span.Single(ctx), spanner.AllKeys(), func(inv *testresults.IngestedInvocation) error {
+					invs = append(invs, inv)
+					return nil
+				})
+				So(err, ShouldBeNil)
+				if expected != nil {
+					So(invs, ShouldHaveLength, 1)
+					So(invs[0], ShouldResemble, expected)
+				} else {
+					So(invs, ShouldHaveLength, 0)
+				}
+			}
+
+			verifyGitReference := func(expected *gitreferences.GitReference) {
+				refs, err := gitreferences.ReadAll(span.Single(ctx))
+				So(err, ShouldBeNil)
+				if expected != nil {
+					So(refs, ShouldHaveLength, 1)
+					actual := refs[0]
+					// LastIngestionTime is a commit timestamp in the
+					// control of the implementation. We check it is
+					// populated and assert nothing beyond that.
+					So(actual.LastIngestionTime, ShouldNotBeEmpty)
+					actual.LastIngestionTime = time.Time{}
+
+					So(actual, ShouldResemble, expected)
+				} else {
+					So(refs, ShouldHaveLength, 0)
+				}
+			}
+
+			verifyTestResults := func(expectCommitPosition bool) {
+				trBuilder := testresults.NewTestResult().
+					WithProject("project").
+					WithPartitionTime(timestamppb.New(partitionTime).AsTime()).
+					WithIngestedInvocationID("build-87654321").
+					WithSubRealm("ci").
+					WithBuildStatus(pb.BuildStatus_BUILD_STATUS_FAILURE).
+					WithChangelists([]testresults.Changelist{
+						{
+							Host:     "anothergerrit",
+							Change:   77788,
+							Patchset: 19,
+						},
+						{
+							Host:     "mygerrit",
+							Change:   12345,
+							Patchset: 5,
+						},
+					}).
+					WithPresubmitRun(&testresults.PresubmitRun{
+						Owner: "automation",
+						Mode:  pb.PresubmitRunMode_FULL_RUN,
+					})
+				if expectCommitPosition {
+					trBuilder = trBuilder.WithCommitPosition(expectedInvocation.GitReferenceHash, expectedInvocation.CommitPosition)
+				} else {
+					trBuilder = trBuilder.WithoutCommitPosition()
+				}
+
+				expectedTRs := []*testresults.TestResult{
+					trBuilder.WithTestID("ninja://test_consistent_failure").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithRunDuration(3*time.Second).
+						WithExonerationReasons(pb.ExonerationReason_OCCURS_ON_OTHER_CLS, pb.ExonerationReason_NOT_CRITICAL, pb.ExonerationReason_OCCURS_ON_MAINLINE).
+						Build(),
+					trBuilder.WithTestID("ninja://test_expected").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(false).
+						WithStatus(pb.TestResultStatus_PASS).
+						WithRunDuration(5 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_has_unexpected").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithoutRunDuration().
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_has_unexpected").
+						WithVariantHash("hash").
+						WithRunIndex(1).
+						WithResultIndex(0).
+						WithIsUnexpected(false).
+						WithStatus(pb.TestResultStatus_PASS).
+						WithoutRunDuration().
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_known_flake").
+						WithVariantHash("hash_2").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithRunDuration(2 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_new_failure").
+						WithVariantHash("hash_1").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithRunDuration(1 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_new_flake").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithRunDuration(10 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_new_flake").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(1).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithRunDuration(11 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_new_flake").
+						WithVariantHash("hash").
+						WithRunIndex(1).
+						WithResultIndex(0).
+						WithIsUnexpected(false).
+						WithStatus(pb.TestResultStatus_PASS).
+						WithRunDuration(12 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_no_new_results").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_FAIL).
+						WithRunDuration(4 * time.Second).
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_skip").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_SKIP).
+						WithoutRunDuration().
+						WithoutExoneration().
+						Build(),
+					trBuilder.WithTestID("ninja://test_unexpected_pass").
+						WithVariantHash("hash").
+						WithRunIndex(0).
+						WithResultIndex(0).
+						WithIsUnexpected(true).
+						WithStatus(pb.TestResultStatus_PASS).
+						WithoutRunDuration().
+						WithoutExoneration().
+						Build(),
+				}
+
+				// Validate TestResults table is populated.
+				var actualTRs []*testresults.TestResult
+				err := testresults.ReadTestResults(span.Single(ctx), spanner.AllKeys(), func(tr *testresults.TestResult) error {
+					actualTRs = append(actualTRs, tr)
+					return nil
+				})
+				So(err, ShouldBeNil)
+				So(actualTRs, ShouldResemble, expectedTRs)
+
+				// Validate TestVariantRealms table is populated.
+				tvrs := make([]*testresults.TestVariantRealm, 0)
+				err = testresults.ReadTestVariantRealms(span.Single(ctx), spanner.AllKeys(), func(tvr *testresults.TestVariantRealm) error {
+					tvrs = append(tvrs, tvr)
+					return nil
+				})
+				So(err, ShouldBeNil)
+
+				expectedRealms := []*testresults.TestVariantRealm{
+					{
+						Project:     "project",
+						TestID:      "ninja://test_consistent_failure",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_expected",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_has_unexpected",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_known_flake",
+						VariantHash: "hash_2",
+						SubRealm:    "ci",
+						Variant:     pbutil.VariantFromResultDB(rdbpbutil.Variant("k1", "v2")),
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_new_failure",
+						VariantHash: "hash_1",
+						SubRealm:    "ci",
+						Variant:     pbutil.VariantFromResultDB(rdbpbutil.Variant("k1", "v1")),
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_new_flake",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_no_new_results",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_skip",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+					{
+						Project:     "project",
+						TestID:      "ninja://test_unexpected_pass",
+						VariantHash: "hash",
+						SubRealm:    "ci",
+						Variant:     nil,
+					},
+				}
+
+				So(tvrs, ShouldHaveLength, len(expectedRealms))
+				for i, tvr := range tvrs {
+					expectedTVR := expectedRealms[i]
+					So(tvr.LastIngestionTime, ShouldNotBeZeroValue)
+					expectedTVR.LastIngestionTime = tvr.LastIngestionTime
+					So(tvr, ShouldResemble, expectedTVR)
+				}
+
+				// Validate TestRealms table is populated.
+				testRealms := make([]*testresults.TestRealm, 0)
+				err = testresults.ReadTestRealms(span.Single(ctx), spanner.AllKeys(), func(tvr *testresults.TestRealm) error {
+					testRealms = append(testRealms, tvr)
+					return nil
+				})
+				So(err, ShouldBeNil)
+
+				expectedTestRealms := []*testresults.TestRealm{
+					{
+						Project:  "project",
+						TestID:   "ninja://test_consistent_failure",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_expected",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_has_unexpected",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_known_flake",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_new_failure",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_new_flake",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_no_new_results",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_skip",
+						SubRealm: "ci",
+					},
+					{
+						Project:  "project",
+						TestID:   "ninja://test_unexpected_pass",
+						SubRealm: "ci",
+					},
+				}
+
+				So(testRealms, ShouldHaveLength, len(expectedTestRealms))
+				for i, tr := range testRealms {
+					expectedTR := expectedTestRealms[i]
+					So(tr.LastIngestionTime, ShouldNotBeZeroValue)
+					expectedTR.LastIngestionTime = tr.LastIngestionTime
+					So(tr, ShouldResemble, expectedTR)
+				}
+			}
+
+			verifyClustering := func() {
+				// Confirm chunks have been written to GCS.
+				So(len(chunkStore.Contents), ShouldEqual, 1)
+
+				// Confirm clustering has occurred, with each test result in at
+				// least one cluster.
+				actualClusteredFailures := make(map[string]int)
+				for project, insertions := range clusteredFailures.InsertionsByProject {
+					So(project, ShouldEqual, "project")
+					for _, f := range insertions {
+						actualClusteredFailures[f.TestId] += 1
+					}
+				}
+				expectedClusteredFailures := map[string]int{
+					"ninja://test_new_failure":        1,
+					"ninja://test_known_flake":        1,
+					"ninja://test_consistent_failure": 1,
+					"ninja://test_no_new_results":     1,
+					"ninja://test_new_flake":          2,
+					"ninja://test_has_unexpected":     1,
+				}
+				So(actualClusteredFailures, ShouldResemble, expectedClusteredFailures)
+			}
+
+			verifyAnalyzedTestVariants := func() {
+				// Read rows from Spanner to confirm the analyzed test variants are saved.
+				ctx, cancel := span.ReadOnlyTransaction(ctx)
+				defer cancel()
+
+				exp := map[string]atvpb.Status{
+					"ninja://test_new_failure":        atvpb.Status_HAS_UNEXPECTED_RESULTS,
+					"ninja://test_known_flake":        atvpb.Status_FLAKY,
+					"ninja://test_consistent_failure": atvpb.Status_CONSISTENTLY_UNEXPECTED,
+					"ninja://test_no_new_results":     atvpb.Status_HAS_UNEXPECTED_RESULTS,
+					"ninja://test_new_flake":          atvpb.Status_FLAKY,
+					"ninja://test_has_unexpected":     atvpb.Status_FLAKY,
+				}
+				act := make(map[string]atvpb.Status)
+				expProtos := map[string]*atvpb.AnalyzedTestVariant{
+					"ninja://test_new_failure": {
+						Realm:        realm,
+						TestId:       "ninja://test_new_failure",
+						VariantHash:  "hash_1",
+						Status:       atvpb.Status_HAS_UNEXPECTED_RESULTS,
+						Variant:      pbutil.VariantFromResultDB(sampleVar),
+						Tags:         pbutil.StringPairs("monorail_component", "Monorail>Component"),
+						TestMetadata: pbutil.TestMetadataFromResultDB(sampleTmd),
+					},
+					"ninja://test_known_flake": {
+						Realm:       realm,
+						TestId:      "ninja://test_known_flake",
+						VariantHash: "hash_2",
+						Status:      atvpb.Status_FLAKY,
+						Tags:        pbutil.StringPairs("monorail_component", "Monorail>Component", "os", "Mac", "test_name", "test_known_flake"),
+					},
+				}
+
+				var testIDsWithNextTask []string
+				fields := []string{"Realm", "TestId", "VariantHash", "Status", "Variant", "Tags", "TestMetadata", "NextUpdateTaskEnqueueTime"}
+				actProtos := make(map[string]*atvpb.AnalyzedTestVariant, len(expProtos))
+				var b spanutil.Buffer
+				err := span.Read(ctx, "AnalyzedTestVariants", spanner.AllKeys(), fields).Do(
+					func(row *spanner.Row) error {
+						tv := &atvpb.AnalyzedTestVariant{}
+						var tmd spanutil.Compressed
+						var enqTime spanner.NullTime
+						err := b.FromSpanner(row, &tv.Realm, &tv.TestId, &tv.VariantHash, &tv.Status, &tv.Variant, &tv.Tags, &tmd, &enqTime)
+						So(err, ShouldBeNil)
+						So(tv.Realm, ShouldEqual, realm)
+
+						if len(tmd) > 0 {
+							tv.TestMetadata = &pb.TestMetadata{}
+							err = proto.Unmarshal(tmd, tv.TestMetadata)
+							So(err, ShouldBeNil)
+						}
+
+						act[tv.TestId] = tv.Status
+						if _, ok := expProtos[tv.TestId]; ok {
+							actProtos[tv.TestId] = tv
+						}
+
+						if !enqTime.IsNull() {
+							testIDsWithNextTask = append(testIDsWithNextTask, tv.TestId)
+						}
+						return nil
+					},
+				)
+				So(err, ShouldBeNil)
+				So(act, ShouldResemble, exp)
+				for k, actProto := range actProtos {
+					v, ok := expProtos[k]
+					So(ok, ShouldBeTrue)
+					So(actProto, ShouldResembleProto, v)
+				}
+				sort.Strings(testIDsWithNextTask)
+
+				var actTestIDsWithTasks []string
+				for _, pl := range skdr.Tasks().Payloads() {
+					switch pl.(type) {
+					case *taskspb.UpdateTestVariant:
+						plp := pl.(*taskspb.UpdateTestVariant)
+						actTestIDsWithTasks = append(actTestIDsWithTasks, plp.TestVariantKey.TestId)
+					default:
+					}
+				}
+				sort.Strings(actTestIDsWithTasks)
+				So(len(actTestIDsWithTasks), ShouldEqual, 3)
+				So(actTestIDsWithTasks, ShouldResemble, testIDsWithNextTask)
+			}
+
+			verifyCollectTask := func(expectExists bool) {
+				expColTask := &taskspb.CollectTestResults{
+					Resultdb: &taskspb.ResultDB{
+						Invocation: &rdbpb.Invocation{
+							Name:  inv,
+							Realm: realm,
+						},
+						Host: "results.api.cr.dev",
+					},
+					Builder:                   "builder",
+					IsPreSubmit:               true,
+					ContributedToClSubmission: true,
+				}
+				collectTaskCount := 0
+				for _, pl := range skdr.Tasks().Payloads() {
+					switch pl.(type) {
+					case *taskspb.CollectTestResults:
+						plp := pl.(*taskspb.CollectTestResults)
+						So(plp, ShouldResembleProto, expColTask)
+						collectTaskCount++
+					default:
+					}
+				}
+				if expectExists {
+					So(collectTaskCount, ShouldEqual, 1)
+				} else {
+					So(collectTaskCount, ShouldEqual, 0)
+				}
+			}
+
+			verifyContinuationTask := func(expectExists bool) {
+				count := 0
+				for _, pl := range skdr.Tasks().Payloads() {
+					switch pl.(type) {
+					case *taskspb.IngestTestResults:
+						plp := pl.(*taskspb.IngestTestResults)
+						So(plp, ShouldResembleProto, &taskspb.IngestTestResults{
+							Build: &ctrlpb.BuildResult{
+								Host:         bHost,
+								Id:           bID,
+								Project:      "project",
+								CreationTime: timestamppb.New(time.Date(2020, time.April, 1, 2, 3, 4, 5, time.UTC)),
+							},
+							PartitionTime: timestamppb.New(partitionTime),
+							PresubmitRun: &ctrlpb.PresubmitResult{
+								PresubmitRunId: &pb.PresubmitRunId{
+									System: "luci-cv",
+									Id:     "infra/12345",
+								},
+								Owner:        "automation",
+								Status:       pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED,
+								Mode:         pb.PresubmitRunMode_FULL_RUN,
+								CreationTime: timestamppb.New(time.Date(2021, time.April, 1, 2, 3, 4, 5, time.UTC)),
+							},
+							PageToken: "continuation_token",
+							TaskIndex: 1,
+						})
+						count++
+					default:
+					}
+				}
+				if expectExists {
+					So(count, ShouldEqual, 1)
+				} else {
+					So(count, ShouldEqual, 0)
+				}
+			}
+
+			verifyIngestionControl := func(expected *control.Entry) {
+				actual, err := control.Read(span.Single(ctx), []string{expected.BuildID})
+				So(err, ShouldBeNil)
+				So(actual, ShouldHaveLength, 1)
+				a := *actual[0]
+				e := *expected
+
+				// Compare protos separately, as they are not compared
+				// correctly by ShouldResemble.
+				So(a.PresubmitResult, ShouldResembleProto, e.PresubmitResult)
+				a.PresubmitResult = nil
+				e.PresubmitResult = nil
+
+				So(a.BuildResult, ShouldResembleProto, e.BuildResult)
+				a.BuildResult = nil
+				e.BuildResult = nil
+
+				// Do not compare last updated time, as it is determined
+				// by commit timestamp.
+				So(a.LastUpdated, ShouldNotBeEmpty)
+				e.LastUpdated = a.LastUpdated
+
+				So(a, ShouldResemble, e)
+			}
+
+			setupGetBuildMock := func(modifiers ...func(*bbpb.Build)) {
+				request := &bbpb.GetBuildRequest{
+					Id: bID,
+					Mask: &bbpb.BuildMask{
+						Fields: buildReadMask,
+					},
+				}
+				response := mockedGetBuildRsp(inv)
+				for _, modifier := range modifiers {
+					modifier(response)
+				}
+				mbc.GetBuild(request, response)
+			}
+
+			setupGetInvocationMock := func() {
+				invReq := &rdbpb.GetInvocationRequest{
+					Name: inv,
+				}
+				invRes := &rdbpb.Invocation{
+					Name:  inv,
+					Realm: realm,
+				}
+				mrc.GetInvocation(invReq, invRes)
+			}
+
+			setupQueryTestVariantsMock := func(modifiers ...func(*rdbpb.QueryTestVariantsResponse)) {
+				tvReq := &rdbpb.QueryTestVariantsRequest{
+					Invocations: []string{inv},
+					PageSize:    10000,
+					ReadMask:    testVariantReadMask,
+					PageToken:   "expected_token",
+				}
+				tvRsp := mockedQueryTestVariantsRsp()
+				tvRsp.NextPageToken = "continuation_token"
+				for _, modifier := range modifiers {
+					modifier(tvRsp)
+				}
+				mrc.QueryTestVariants(tvReq, tvRsp)
+			}
+
+			// Prepare some existing analyzed test variants to update.
+			ms := []*spanner.Mutation{
+				// Known flake's status should remain unchanged.
+				insert.AnalyzedTestVariant(realm, "ninja://test_known_flake", "hash_2", atvpb.Status_FLAKY, map[string]interface{}{
+					"Tags": pbutil.StringPairs("test_name", "test_known_flake", "monorail_component", "Monorail>OldComponent"),
+				}),
+				// Non-flake test variant's status will change when see a flaky occurrence.
+				insert.AnalyzedTestVariant(realm, "ninja://test_has_unexpected", "hash", atvpb.Status_HAS_UNEXPECTED_RESULTS, nil),
+				// Consistently failed test variant.
+				insert.AnalyzedTestVariant(realm, "ninja://test_consistent_failure", "hash", atvpb.Status_CONSISTENTLY_UNEXPECTED, nil),
+				// Stale test variant has new failure.
+				insert.AnalyzedTestVariant(realm, "ninja://test_no_new_results", "hash", atvpb.Status_NO_NEW_RESULTS, nil),
+			}
+			testutil.MustApply(ctx, ms...)
+
+			payload := &taskspb.IngestTestResults{
+				Build: &ctrlpb.BuildResult{
+					Host:         bHost,
+					Id:           bID,
+					Project:      "project",
+					CreationTime: timestamppb.New(time.Date(2020, time.April, 1, 2, 3, 4, 5, time.UTC)),
+				},
+				PartitionTime: timestamppb.New(partitionTime),
+				PresubmitRun: &ctrlpb.PresubmitResult{
+					PresubmitRunId: &pb.PresubmitRunId{
+						System: "luci-cv",
+						Id:     "infra/12345",
+					},
+					Status:       pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED,
+					Mode:         pb.PresubmitRunMode_FULL_RUN,
+					Owner:        "automation",
+					CreationTime: timestamppb.New(time.Date(2021, time.April, 1, 2, 3, 4, 5, time.UTC)),
+				},
+				PageToken: "expected_token",
+				TaskIndex: 0,
+			}
+
+			ingestionCtl :=
+				control.NewEntry(0).
+					WithBuildID(control.BuildID(bHost, bID)).
+					WithBuildResult(proto.Clone(payload.Build).(*ctrlpb.BuildResult)).
+					WithPresubmitResult(proto.Clone(payload.PresubmitRun).(*ctrlpb.PresubmitResult)).
+					WithTaskCount(1).
+					Build()
+
+			Convey("First task", func() {
+				setupGetBuildMock()
+				setupGetInvocationMock()
+				setupQueryTestVariantsMock()
+				_, err := control.SetEntriesForTesting(ctx, ingestionCtl)
+				So(err, ShouldBeNil)
+
+				// Act
+				err = ri.ingestTestResults(ctx, payload)
+				So(err, ShouldBeNil)
+
+				// Verify
+				verifyIngestedInvocation(expectedInvocation)
+				verifyGitReference(expectedGitReference)
+
+				// Expect a continuation task to be created.
+				expectContinuation := true
+				verifyContinuationTask(expectContinuation)
+				ingestionCtl.TaskCount = ingestionCtl.TaskCount + 1 // Expect to have been incremented.
+				verifyIngestionControl(ingestionCtl)
+				expectCommitPosition := true
+				verifyTestResults(expectCommitPosition)
+				verifyClustering()
+				verifyAnalyzedTestVariants()
+				expectCollectTaskExists := false
+				verifyCollectTask(expectCollectTaskExists)
+			})
+			Convey("Last task", func() {
+				payload.TaskIndex = 10
+				ingestionCtl.TaskCount = 11
+
+				setupGetBuildMock()
+				setupGetInvocationMock()
+				setupQueryTestVariantsMock(func(rsp *rdbpb.QueryTestVariantsResponse) {
+					rsp.NextPageToken = ""
+				})
+
+				_, err := control.SetEntriesForTesting(ctx, ingestionCtl)
+				So(err, ShouldBeNil)
+
+				// Act
+				err = ri.ingestTestResults(ctx, payload)
+				So(err, ShouldBeNil)
+
+				// Verify
+				// Only the first task should create the ingested
+				// invocation record and git reference record (if any).
+				verifyIngestedInvocation(nil)
+				verifyGitReference(nil)
+
+				// As this is the last task, do not expect a continuation
+				// task to be created.
+				expectContinuation := false
+				verifyContinuationTask(expectContinuation)
+				verifyIngestionControl(ingestionCtl)
+				expectCommitPosition := true
+				verifyTestResults(expectCommitPosition)
+				verifyClustering()
+				verifyAnalyzedTestVariants()
+
+				// Expect a collect task to be created.
+				expectCollectTaskExists := true
+				verifyCollectTask(expectCollectTaskExists)
+			})
+			Convey("Retry task after continuation task already created", func() {
+				// Scenario: First task fails after it has already scheduled
+				// its continuation.
+				ingestionCtl.TaskCount = 2
+
+				setupGetBuildMock()
+				setupGetInvocationMock()
+				setupQueryTestVariantsMock()
+				_, err := control.SetEntriesForTesting(ctx, ingestionCtl)
+				So(err, ShouldBeNil)
+
+				// Act
+				err = ri.ingestTestResults(ctx, payload)
+				So(err, ShouldBeNil)
+
+				// Verify
+				verifyIngestedInvocation(expectedInvocation)
+				verifyGitReference(expectedGitReference)
+
+				// Do not expect a continuation task to be created,
+				// as it was already scheduled.
+				expectContinuation := false
+				verifyContinuationTask(expectContinuation)
+				verifyIngestionControl(ingestionCtl)
+				expectCommitPosition := true
+				verifyTestResults(expectCommitPosition)
+				verifyClustering()
+				verifyAnalyzedTestVariants()
+
+				expectCollectTaskExists := false
+				verifyCollectTask(expectCollectTaskExists)
+			})
+			Convey("No commit position", func() {
+				// Scenario: The build which completed did not include commit
+				// position data in its output or input.
+
+				setupGetBuildMock(func(b *bbpb.Build) {
+					b.Input.GitilesCommit = nil
+					b.Output.GitilesCommit = nil
+				})
+				setupGetInvocationMock()
+				setupQueryTestVariantsMock()
+				_, err := control.SetEntriesForTesting(ctx, ingestionCtl)
+				So(err, ShouldBeNil)
+
+				// Act
+				err = ri.ingestTestResults(ctx, payload)
+				So(err, ShouldBeNil)
+
+				// Verify
+				// The ingested invocation record should not record
+				// the commit position.
+				expectedInvocation.CommitHash = ""
+				expectedInvocation.CommitPosition = 0
+				expectedInvocation.GitReferenceHash = nil
+				verifyIngestedInvocation(expectedInvocation)
+
+				// No git reference record should be created.
+				verifyGitReference(nil)
+
+				// Test results should not have a commit position.
+				expectCommitPosition := false
+				verifyTestResults(expectCommitPosition)
+			})
+			Convey("No project config", func() {
+				// If no project config exists, results should be ingested into
+				// TestResults, but not clustered or used for test variant
+				// analysis.
+				config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
+
+				setupGetBuildMock()
+				setupGetInvocationMock()
+				setupQueryTestVariantsMock()
+				_, err := control.SetEntriesForTesting(ctx, ingestionCtl)
+				So(err, ShouldBeNil)
+
+				// Act
+				err = ri.ingestTestResults(ctx, payload)
+				So(err, ShouldBeNil)
+
+				// Verify
+				// Test results still ingested.
+				expectCommitPosition := true
+				verifyTestResults(expectCommitPosition)
+
+				// Confirm no chunks have been written to GCS.
+				So(len(chunkStore.Contents), ShouldEqual, 0)
+				// Confirm no clustering has occurred.
+				So(clusteredFailures.InsertionsByProject, ShouldHaveLength, 0)
+			})
+			Convey(`builds with ancestor IDs`, func() {
+				setupGetBuildMock(func(b *bbpb.Build) {
+					// 999324 is the immediate ancestor, 777121 is the
+					// ancestor's ancestor.
+					b.AncestorIds = []int64{777121, 999324}
+				})
+
+				// Setup response for the immediate ancestor build.
+				ancestorRequest := &bbpb.GetBuildRequest{
+					Id: 999324,
+					Mask: &bbpb.BuildMask{
+						Fields: buildReadMask,
+					},
+				}
+				ancestorResponse := mockedGetBuildRsp(inv)
+
+				Convey(`build not ingested if ancestor has ResultDB invocation`, func() {
+					mbc.GetBuild(ancestorRequest, ancestorResponse)
+
+					// Act
+					err := ri.ingestTestResults(ctx, payload)
+					So(err, ShouldBeNil)
+
+					// Verify no test results ingested into test history.
+					var actualTRs []*testresults.TestResult
+					err = testresults.ReadTestResults(span.Single(ctx), spanner.AllKeys(), func(tr *testresults.TestResult) error {
+						actualTRs = append(actualTRs, tr)
+						return nil
+					})
+					So(err, ShouldBeNil)
+					So(actualTRs, ShouldHaveLength, 0)
+				})
+				Convey(`build ingested if ancestor has no ResultDB invocation`, func() {
+					ancestorResponse.Infra.Resultdb = nil
+					mbc.GetBuild(ancestorRequest, ancestorResponse)
+
+					// Setup other mocks required to support test result
+					// ingestion.
+					setupGetInvocationMock()
+					setupQueryTestVariantsMock()
+					_, err := control.SetEntriesForTesting(ctx, ingestionCtl)
+					So(err, ShouldBeNil)
+
+					// Act
+					err = ri.ingestTestResults(ctx, payload)
+					So(err, ShouldBeNil)
+
+					// Verify test results still ingested.
+					expectCommitPosition := true
+					verifyTestResults(expectCommitPosition)
+				})
+			})
+		})
+	})
+}
diff --git a/analysis/internal/services/resultingester/main_test.go b/analysis/internal/services/resultingester/main_test.go
new file mode 100644
index 0000000..98caa61
--- /dev/null
+++ b/analysis/internal/services/resultingester/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultingester
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/services/resultingester/test_data.go b/analysis/internal/services/resultingester/test_data.go
new file mode 100644
index 0000000..4fcec2c
--- /dev/null
+++ b/analysis/internal/services/resultingester/test_data.go
@@ -0,0 +1,289 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultingester
+
+import (
+	"strings"
+	"time"
+
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/common/proto/mask"
+	"go.chromium.org/luci/resultdb/pbutil"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"google.golang.org/protobuf/types/known/durationpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+)
+
+var sampleVar = pbutil.Variant("k1", "v1")
+var sampleTmd = &rdbpb.TestMetadata{
+	Name: "test_new_failure",
+}
+
+func mockedGetBuildRsp(inv string) *bbpb.Build {
+	build := &bbpb.Build{
+		Builder: &bbpb.BuilderID{
+			Project: "project",
+			Bucket:  "ci",
+			Builder: "builder",
+		},
+		Infra: &bbpb.BuildInfra{
+			Resultdb: &bbpb.BuildInfra_ResultDB{
+				Hostname:   "results.api.cr.dev",
+				Invocation: inv,
+			},
+		},
+		Status: bbpb.Status_FAILURE,
+		Input: &bbpb.Build_Input{
+			GerritChanges: []*bbpb.GerritChange{
+				{
+					Host:     "mygerrit-review.googlesource.com",
+					Change:   12345,
+					Patchset: 5,
+				},
+				{
+					Host:     "anothergerrit-review.googlesource.com",
+					Change:   77788,
+					Patchset: 19,
+				},
+			},
+		},
+		Output: &bbpb.Build_Output{
+			GitilesCommit: &bbpb.GitilesCommit{
+				Host:     "myproject.googlesource.com",
+				Project:  "someproject/src",
+				Id:       strings.Repeat("0a", 20),
+				Ref:      "refs/heads/mybranch",
+				Position: 111888,
+			},
+		},
+		AncestorIds: []int64{},
+	}
+
+	isFieldNameJSON := false
+	isUpdateMask := false
+	m, err := mask.FromFieldMask(buildReadMask, build, isFieldNameJSON, isUpdateMask)
+	if err != nil {
+		panic(err)
+	}
+	if err := m.Trim(build); err != nil {
+		panic(err)
+	}
+	return build
+}
+
+func mockedQueryTestVariantsRsp() *rdbpb.QueryTestVariantsResponse {
+	response := &rdbpb.QueryTestVariantsResponse{
+		TestVariants: []*rdbpb.TestVariant{
+			{
+				TestId:      "ninja://test_consistent_failure",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_EXONERATED,
+				Exonerations: []*rdbpb.TestExoneration{
+					// Test behaviour in the presence of multiple exoneration reasons.
+					{
+						Reason: rdbpb.ExonerationReason_OCCURS_ON_OTHER_CLS,
+					},
+					{
+						Reason: rdbpb.ExonerationReason_NOT_CRITICAL,
+					},
+					{
+						Reason: rdbpb.ExonerationReason_OCCURS_ON_MAINLINE,
+					},
+				},
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/build-1234/tests/ninja%3A%2F%2Ftest_consistent_failure/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.March, 1, 0, 0, 0, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+							Duration:  durationpb.New(time.Second * 3),
+						},
+					},
+				},
+			},
+			// Should ignore for test variant analysis.
+			{
+				TestId:      "ninja://test_expected",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_EXPECTED,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/build-1234/tests/ninja%3A%2F%2Ftest_expected/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.May, 1, 0, 0, 0, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_PASS,
+							Expected:  true,
+							Duration:  durationpb.New(time.Second * 5),
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_has_unexpected",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_FLAKY,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/invocation-0b/tests/ninja%3A%2F%2Ftest_has_unexpected/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.February, 1, 0, 0, 10, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+						},
+					},
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/invocation-0a/tests/ninja%3A%2F%2Ftest_has_unexpected/results/two",
+							StartTime: timestamppb.New(time.Date(2010, time.February, 1, 0, 0, 20, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_PASS,
+							Expected:  true,
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_known_flake",
+				VariantHash: "hash_2",
+				Status:      rdbpb.TestVariantStatus_UNEXPECTED,
+				Variant:     pbutil.Variant("k1", "v2"),
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/build-1234/tests/ninja%3A%2F%2Ftest_known_flake/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.February, 1, 0, 0, 0, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+							Duration:  durationpb.New(time.Second * 2),
+							Tags:      pbutil.StringPairs("os", "Mac", "monorail_component", "Monorail>Component"),
+						},
+					},
+				},
+			},
+			{
+				TestId:       "ninja://test_new_failure",
+				VariantHash:  "hash_1",
+				Status:       rdbpb.TestVariantStatus_UNEXPECTED,
+				Variant:      pbutil.Variant("k1", "v1"),
+				TestMetadata: sampleTmd,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/build-1234/tests/ninja%3A%2F%2Ftest_new_failure/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.January, 1, 0, 0, 0, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+							Duration:  durationpb.New(time.Second * 1),
+							Tags:      pbutil.StringPairs("random_tag", "random_tag_value", "monorail_component", "Monorail>Component"),
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_new_flake",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_FLAKY,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/invocation-1234/tests/ninja%3A%2F%2Ftest_new_flake/results/two",
+							StartTime: timestamppb.New(time.Date(2010, time.January, 1, 0, 0, 20, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+							Duration:  durationpb.New(time.Second * 11),
+						},
+					},
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/invocation-1234/tests/ninja%3A%2F%2Ftest_new_flake/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.January, 1, 0, 0, 10, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+							Duration:  durationpb.New(time.Second * 10),
+						},
+					},
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/invocation-4567/tests/ninja%3A%2F%2Ftest_new_flake/results/three",
+							StartTime: timestamppb.New(time.Date(2010, time.January, 1, 0, 0, 15, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_PASS,
+							Expected:  true,
+							Duration:  durationpb.New(time.Second * 12),
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_no_new_results",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_UNEXPECTED,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/build-1234/tests/ninja%3A%2F%2Ftest_no_new_results/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.April, 1, 0, 0, 0, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_FAIL,
+							Expected:  false,
+							Duration:  durationpb.New(time.Second * 4),
+						},
+					},
+				},
+			},
+			// Should ignore for test variant analysis.
+			{
+				TestId:      "ninja://test_skip",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_UNEXPECTEDLY_SKIPPED,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:      "invocations/build-1234/tests/ninja%3A%2F%2Ftest_skip/results/one",
+							StartTime: timestamppb.New(time.Date(2010, time.February, 2, 0, 0, 0, 0, time.UTC)),
+							Status:    rdbpb.TestStatus_SKIP,
+							Expected:  false,
+						},
+					},
+				},
+			},
+			{
+				TestId:      "ninja://test_unexpected_pass",
+				VariantHash: "hash",
+				Status:      rdbpb.TestVariantStatus_UNEXPECTED,
+				Results: []*rdbpb.TestResultBundle{
+					{
+						Result: &rdbpb.TestResult{
+							Name:     "invocations/build-1234/tests/ninja%3A%2F%2Ftest_unexpected_pass/results/one",
+							Status:   rdbpb.TestStatus_PASS,
+							Expected: false,
+						},
+					},
+				},
+			},
+		},
+	}
+
+	isFieldNameJSON := false
+	isUpdateMask := false
+	m, err := mask.FromFieldMask(testVariantReadMask, &rdbpb.TestVariant{}, isFieldNameJSON, isUpdateMask)
+	if err != nil {
+		panic(err)
+	}
+	for _, tv := range response.TestVariants {
+		if err := m.Trim(tv); err != nil {
+			panic(err)
+		}
+	}
+	return response
+}
diff --git a/analysis/internal/services/resultingester/test_results.go b/analysis/internal/services/resultingester/test_results.go
new file mode 100644
index 0000000..b2dd3cc
--- /dev/null
+++ b/analysis/internal/services/resultingester/test_results.go
@@ -0,0 +1,327 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package resultingester
+
+import (
+	"context"
+	"fmt"
+	"strings"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	bbpb "go.chromium.org/luci/buildbucket/proto"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/sync/parallel"
+	"go.chromium.org/luci/common/trace"
+	rdbpbutil "go.chromium.org/luci/resultdb/pbutil"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/ingestion/resultdb"
+	"go.chromium.org/luci/analysis/internal/perms"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testresults"
+	"go.chromium.org/luci/analysis/internal/testresults/gitreferences"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// maximumCLs is the maximum number of changelists to capture from any
+// buildbucket run, after which the CL list is truncated. This avoids builds
+// with an excessive number of included CLs from storing an excessive amount
+// of data per failure.
+const maximumCLs = 10
+
+// extractGitReference extracts the git reference used to number the commit
+// tested by the given build.
+func extractGitReference(project string, commit *bbpb.GitilesCommit) *gitreferences.GitReference {
+	return &gitreferences.GitReference{
+		Project:          project,
+		GitReferenceHash: gitreferences.GitReferenceHash(commit.Host, commit.Project, commit.Ref),
+		Hostname:         commit.Host,
+		Repository:       commit.Project,
+		Reference:        commit.Ref,
+	}
+}
+
+// extractIngestionContext extracts the ingested invocation and
+// the git reference tested (if any).
+func extractIngestionContext(task *taskspb.IngestTestResults, build *bbpb.Build, inv *rdbpb.Invocation) (*testresults.IngestedInvocation, *gitreferences.GitReference, error) {
+	invID, err := rdbpbutil.ParseInvocationName(inv.Name)
+	if err != nil {
+		// This should never happen. Inv was originated from ResultDB.
+		panic(err)
+	}
+
+	proj, subRealm, err := perms.SplitRealm(inv.Realm)
+	if err != nil {
+		return nil, nil, errors.Annotate(err, "invocation has invalid realm: %q", inv.Realm).Err()
+	}
+	if proj != task.Build.Project {
+		return nil, nil, errors.Reason("invocation project (%q) does not match build project (%q) for build %s-%d",
+			proj, task.Build.Project, task.Build.Host, task.Build.Id).Err()
+	}
+
+	var buildStatus pb.BuildStatus
+	switch build.Status {
+	case bbpb.Status_CANCELED:
+		buildStatus = pb.BuildStatus_BUILD_STATUS_CANCELED
+	case bbpb.Status_SUCCESS:
+		buildStatus = pb.BuildStatus_BUILD_STATUS_SUCCESS
+	case bbpb.Status_FAILURE:
+		buildStatus = pb.BuildStatus_BUILD_STATUS_FAILURE
+	case bbpb.Status_INFRA_FAILURE:
+		buildStatus = pb.BuildStatus_BUILD_STATUS_INFRA_FAILURE
+	default:
+		return nil, nil, fmt.Errorf("build has unknown status: %v", build.Status)
+	}
+
+	gerritChanges := build.GetInput().GetGerritChanges()
+	changelists := make([]testresults.Changelist, 0, len(gerritChanges))
+	for _, change := range gerritChanges {
+		if !strings.HasSuffix(change.Host, testresults.GerritHostnameSuffix) {
+			return nil, nil, fmt.Errorf(`gerrit host %q does not end in expected suffix %q`, change.Host, testresults.GerritHostnameSuffix)
+		}
+		host := strings.TrimSuffix(change.Host, testresults.GerritHostnameSuffix)
+		changelists = append(changelists, testresults.Changelist{
+			Host:     host,
+			Change:   change.Change,
+			Patchset: change.Patchset,
+		})
+	}
+	// Store the tested changelists in sorted order. This ensures that for
+	// the same combination of CLs tested, the arrays are identical.
+	testresults.SortChangelists(changelists)
+
+	// Truncate the list of changelists to avoid storing an excessive number.
+	// Apply truncation after sorting to ensure a stable set of changelists.
+	if len(changelists) > maximumCLs {
+		changelists = changelists[:maximumCLs]
+	}
+
+	var presubmitRun *testresults.PresubmitRun
+	if task.PresubmitRun != nil {
+		presubmitRun = &testresults.PresubmitRun{
+			Mode:  task.PresubmitRun.Mode,
+			Owner: task.PresubmitRun.Owner,
+		}
+	}
+
+	invocation := &testresults.IngestedInvocation{
+		Project:              proj,
+		IngestedInvocationID: invID,
+		SubRealm:             subRealm,
+		PartitionTime:        task.PartitionTime.AsTime(),
+		BuildStatus:          buildStatus,
+		PresubmitRun:         presubmitRun,
+		Changelists:          changelists,
+	}
+
+	commit := build.Output.GetGitilesCommit()
+	if commit == nil {
+		commit = build.Input.GetGitilesCommit()
+	}
+	var gitRef *gitreferences.GitReference
+	if commit != nil {
+		gitRef = extractGitReference(proj, commit)
+		invocation.GitReferenceHash = gitRef.GitReferenceHash
+		invocation.CommitPosition = int64(commit.Position)
+		invocation.CommitHash = strings.ToLower(commit.Id)
+	}
+	return invocation, gitRef, nil
+}
+
+func recordIngestionContext(ctx context.Context, inv *testresults.IngestedInvocation, gitRef *gitreferences.GitReference) error {
+	// Update the IngestedInvocations table.
+	m := inv.SaveUnverified()
+
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		span.BufferWrite(ctx, m)
+
+		if gitRef != nil {
+			// Ensure the git reference (if any) exists in the GitReferences table.
+			if err := gitreferences.EnsureExists(ctx, gitRef); err != nil {
+				return errors.Annotate(err, "ensuring git reference").Err()
+			}
+		}
+		return nil
+	})
+	return err
+}
+
+type batch struct {
+	// The test realms to insert/update.
+	// Test realms should be inserted before any test variant realms.
+	testRealms []*spanner.Mutation
+	// The test variant realms to insert/update.
+	// Test variant realms should be inserted before any test results.
+	testVariantRealms []*spanner.Mutation
+	// Test results to insert. Already prepared as Spanner mutations.
+	testResults []*spanner.Mutation
+}
+
+func batchTestResults(inv *testresults.IngestedInvocation, tvs []*rdbpb.TestVariant, outputC chan batch) {
+	// Must be selected such that no more than 20,000 mutations occur in
+	// one transaction in the worst case.
+	const batchSize = 900
+
+	var trs []*spanner.Mutation
+	var tvrs []*spanner.Mutation
+	testRealmSet := make(map[testresults.TestRealm]bool, batchSize)
+	startBatch := func() {
+		trs = make([]*spanner.Mutation, 0, batchSize)
+		tvrs = make([]*spanner.Mutation, 0, batchSize)
+		testRealmSet = make(map[testresults.TestRealm]bool, batchSize)
+	}
+	outputBatch := func() {
+		if len(trs) == 0 {
+			// This should never happen.
+			panic("Pushing empty batch")
+		}
+		testRealms := make([]*spanner.Mutation, 0, len(testRealmSet))
+		for testRealm := range testRealmSet {
+			testRealms = append(testRealms, testRealm.SaveUnverified())
+		}
+
+		outputC <- batch{
+			testRealms:        testRealms,
+			testVariantRealms: tvrs,
+			testResults:       trs,
+		}
+	}
+
+	startBatch()
+	for _, tv := range tvs {
+		// Limit batch size.
+		// Keep all results for one test variant in one batch, so that the
+		// TestVariantRealm record is kept together with the test results.
+		if len(trs) > batchSize {
+			outputBatch()
+			startBatch()
+		}
+
+		testRealm := testresults.TestRealm{
+			Project:           inv.Project,
+			TestID:            tv.TestId,
+			SubRealm:          inv.SubRealm,
+			LastIngestionTime: spanner.CommitTimestamp,
+		}
+		testRealmSet[testRealm] = true
+
+		tvr := testresults.TestVariantRealm{
+			Project:           inv.Project,
+			TestID:            tv.TestId,
+			VariantHash:       tv.VariantHash,
+			SubRealm:          inv.SubRealm,
+			Variant:           pbutil.VariantFromResultDB(tv.Variant),
+			LastIngestionTime: spanner.CommitTimestamp,
+		}
+		tvrs = append(tvrs, tvr.SaveUnverified())
+
+		exonerationReasons := make([]pb.ExonerationReason, 0, len(tv.Exonerations))
+		for _, ex := range tv.Exonerations {
+			exonerationReasons = append(exonerationReasons, pbutil.ExonerationReasonFromResultDB(ex.Reason))
+		}
+
+		// Group results into test runs and order them by start time.
+		resultsByRun := resultdb.GroupAndOrderTestResults(tv.Results)
+		for runIndex, run := range resultsByRun {
+			for resultIndex, inputTR := range run {
+				tr := testresults.TestResult{
+					Project:              inv.Project,
+					TestID:               tv.TestId,
+					PartitionTime:        inv.PartitionTime,
+					VariantHash:          tv.VariantHash,
+					IngestedInvocationID: inv.IngestedInvocationID,
+					RunIndex:             int64(runIndex),
+					ResultIndex:          int64(resultIndex),
+					IsUnexpected:         !inputTR.Result.Expected,
+					Status:               pbutil.TestResultStatusFromResultDB(inputTR.Result.Status),
+					ExonerationReasons:   exonerationReasons,
+					SubRealm:             inv.SubRealm,
+					BuildStatus:          inv.BuildStatus,
+					PresubmitRun:         inv.PresubmitRun,
+					GitReferenceHash:     inv.GitReferenceHash,
+					CommitPosition:       inv.CommitPosition,
+					Changelists:          inv.Changelists,
+				}
+				if inputTR.Result.Duration != nil {
+					d := new(time.Duration)
+					*d = inputTR.Result.Duration.AsDuration()
+					tr.RunDuration = d
+				}
+				// Convert the test result into a mutation immediately
+				// to avoid storing both the TestResult object and
+				// mutation object in memory until the transaction
+				// commits.
+				trs = append(trs, tr.SaveUnverified())
+			}
+		}
+	}
+	if len(trs) > 0 {
+		outputBatch()
+	}
+}
+
+// recordTestResults records test results from an test-verdict-ingestion task.
+func recordTestResults(ctx context.Context, inv *testresults.IngestedInvocation, tvs []*rdbpb.TestVariant) (err error) {
+	ctx, s := trace.StartSpan(ctx, "go.chromium.org/luci/analysis/internal/services/resultingester.recordTestResults")
+	defer func() { s.End(err) }()
+
+	const workerCount = 8
+
+	return parallel.WorkPool(workerCount, func(c chan<- func() error) {
+		batchC := make(chan batch)
+
+		c <- func() error {
+			defer close(batchC)
+			batchTestResults(inv, tvs, batchC)
+			return nil
+		}
+
+		for batch := range batchC {
+			// Bind to a local variable so it can be used in a goroutine without being
+			// overwritten. See https://go.dev/doc/faq#closures_and_goroutines
+			batch := batch
+
+			c <- func() error {
+				// Write to different tables in different transactions to minimize the
+				// number of splits involved in each transaction.
+				_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					span.BufferWrite(ctx, batch.testRealms...)
+					return nil
+				})
+				if err != nil {
+					return errors.Annotate(err, "inserting test realms").Err()
+				}
+				_, err = span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					span.BufferWrite(ctx, batch.testVariantRealms...)
+					return nil
+				})
+				if err != nil {
+					return errors.Annotate(err, "inserting test variant realms").Err()
+				}
+				_, err = span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					span.BufferWrite(ctx, batch.testResults...)
+					return nil
+				})
+				if err != nil {
+					return errors.Annotate(err, "inserting test results").Err()
+				}
+				return nil
+			}
+		}
+	})
+}
diff --git a/analysis/internal/services/testvariantbqexporter/export_rows.go b/analysis/internal/services/testvariantbqexporter/export_rows.go
new file mode 100644
index 0000000..67fd18f
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/export_rows.go
@@ -0,0 +1,112 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"context"
+	"net/http"
+
+	"cloud.google.com/go/bigquery"
+	"golang.org/x/sync/semaphore"
+	"golang.org/x/time/rate"
+	"google.golang.org/api/option"
+
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/realms"
+	"go.chromium.org/luci/server/caching"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const (
+	maxBatchRowCount  = 1000
+	rateLimit         = 100
+	maxBatchTotalSize = 200 * 1000 * 1000 // instance memory limit is 512 MB.
+	rowSizeApprox     = 2000
+)
+
+// schemaApplyer ensures BQ schema matches the row proto definitions.
+var schemaApplyer = bq.NewSchemaApplyer(caching.RegisterLRUCache(50))
+
+// Options specifies the requirements of the bq export.
+type Options struct {
+	Realm        string
+	CloudProject string
+	Dataset      string
+	Table        string
+	Predicate    *atvpb.Predicate
+	TimeRange    *pb.TimeRange
+}
+
+// BQExporter exports test variant rows to the dedicated table.
+type BQExporter struct {
+	options *Options
+
+	client *bigquery.Client
+
+	// putLimiter limits the rate of bigquery.Inserter.Put calls.
+	putLimiter *rate.Limiter
+
+	// batchSem limits the number of batches we hold in memory at a time.
+	batchSem *semaphore.Weighted
+}
+
+func CreateBQExporter(options *Options) *BQExporter {
+	if options.Predicate == nil {
+		options.Predicate = &atvpb.Predicate{}
+	}
+	return &BQExporter{
+		options:    options,
+		putLimiter: rate.NewLimiter(rateLimit, 1),
+		batchSem:   semaphore.NewWeighted(int64(maxBatchTotalSize / rowSizeApprox / maxBatchRowCount)),
+	}
+}
+
+func (b *BQExporter) createBQClient(ctx context.Context) error {
+	project, _ := realms.Split(b.options.Realm)
+	tr, err := auth.GetRPCTransport(ctx, auth.AsProject, auth.WithProject(project), auth.WithScopes(bigquery.Scope))
+	if err != nil {
+		return err
+	}
+
+	b.client, err = bigquery.NewClient(ctx, b.options.CloudProject, option.WithHTTPClient(&http.Client{
+		Transport: tr,
+	}))
+	return err
+}
+
+// ExportRows test variants in batch.
+func (b *BQExporter) ExportRows(ctx context.Context) error {
+	err := b.createBQClient(ctx)
+	if err != nil {
+		return err
+	}
+
+	table := b.client.Dataset(b.options.Dataset).Table(b.options.Table)
+	if err = schemaApplyer.EnsureTable(ctx, table, tableMetadata); err != nil {
+		return errors.Annotate(err, "ensuring test variant table in dataset %q", b.options.Dataset).Err()
+	}
+
+	inserter := bqutil.NewInserter(table, maxBatchRowCount)
+	if err = b.exportTestVariantRows(ctx, inserter); err != nil {
+		return errors.Annotate(err, "export test variant rows").Err()
+	}
+
+	return nil
+}
diff --git a/analysis/internal/services/testvariantbqexporter/export_rows_test.go b/analysis/internal/services/testvariantbqexporter/export_rows_test.go
new file mode 100644
index 0000000..25464a5
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/export_rows_test.go
@@ -0,0 +1,556 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"sync"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/common/clock"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+type mockPassInserter struct {
+	insertedMessages []*bq.Row
+	mu               sync.Mutex
+}
+
+func (i *mockPassInserter) PutWithRetries(ctx context.Context, src []*bq.Row) error {
+	i.mu.Lock()
+	i.insertedMessages = append(i.insertedMessages, src...)
+	i.mu.Unlock()
+	return nil
+}
+
+type mockFailInserter struct {
+}
+
+func (i *mockFailInserter) PutWithRetries(ctx context.Context, src []*bq.Row) error {
+	return fmt.Errorf("some error")
+}
+
+func TestQueryTestVariantsToExport(t *testing.T) {
+	Convey(`queryTestVariantsToExport`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		realm := "chromium:ci"
+		tID1 := "ninja://test1"
+		tID2 := "ninja://test2"
+		tID3 := "ninja://test3"
+		tID4 := "ninja://test4"
+		tID5 := "ninja://test5"
+		tID6 := "ninja://test6"
+		variant := pbutil.Variant("builder", "Linux Tests")
+		vh := "varianthash"
+		tags := pbutil.StringPairs("k1", "v1")
+		tmd := &pb.TestMetadata{
+			Location: &pb.TestLocation{
+				Repo:     "https://chromium.googlesource.com/chromium/src",
+				FileName: "//a_test.go",
+			},
+		}
+		tmdM, _ := proto.Marshal(tmd)
+		now := clock.Now(ctx).Round(time.Microsecond)
+		start := clock.Now(ctx).Add(-time.Hour)
+		twoAndHalfHAgo := now.Add(-150 * time.Minute)
+		oneAndHalfHAgo := now.Add(-90 * time.Minute)
+		halfHAgo := now.Add(-30 * time.Minute)
+		m46Ago := now.Add(-46 * time.Minute)
+		ms := []*spanner.Mutation{
+			insert.AnalyzedTestVariant(realm, tID1, vh, atvpb.Status_FLAKY, map[string]interface{}{
+				"Variant":          variant,
+				"Tags":             tags,
+				"TestMetadata":     span.Compressed(tmdM),
+				"StatusUpdateTime": start.Add(-time.Hour),
+			}),
+			// New flaky test variant.
+			insert.AnalyzedTestVariant(realm, tID2, vh, atvpb.Status_FLAKY, map[string]interface{}{
+				"Variant":          variant,
+				"Tags":             tags,
+				"TestMetadata":     span.Compressed(tmdM),
+				"StatusUpdateTime": halfHAgo,
+			}),
+			// Flaky test with no verdicts in time range.
+			insert.AnalyzedTestVariant(realm, tID3, vh, atvpb.Status_FLAKY, map[string]interface{}{
+				"Variant":          variant,
+				"Tags":             tags,
+				"TestMetadata":     span.Compressed(tmdM),
+				"StatusUpdateTime": start.Add(-time.Hour),
+			}),
+			// Test variant with another status is not exported.
+			insert.AnalyzedTestVariant(realm, tID4, vh, atvpb.Status_CONSISTENTLY_UNEXPECTED, map[string]interface{}{
+				"Variant":          variant,
+				"Tags":             tags,
+				"TestMetadata":     span.Compressed(tmdM),
+				"StatusUpdateTime": start.Add(-time.Hour),
+			}),
+			// Test variant has multiple status updates.
+			insert.AnalyzedTestVariant(realm, tID5, vh, atvpb.Status_FLAKY, map[string]interface{}{
+				"Variant":          variant,
+				"Tags":             tags,
+				"TestMetadata":     span.Compressed(tmdM),
+				"StatusUpdateTime": halfHAgo,
+				"PreviousStatuses": []atvpb.Status{
+					atvpb.Status_CONSISTENTLY_EXPECTED,
+					atvpb.Status_FLAKY},
+				"PreviousStatusUpdateTimes": []time.Time{
+					m46Ago,
+					now.Add(-24 * time.Hour)},
+			}),
+			// Test variant with different variant.
+			insert.AnalyzedTestVariant(realm, tID6, "c467ccce5a16dc72", atvpb.Status_CONSISTENTLY_EXPECTED, map[string]interface{}{
+				"Variant":          pbutil.Variant("a", "b"),
+				"Tags":             tags,
+				"TestMetadata":     span.Compressed(tmdM),
+				"StatusUpdateTime": twoAndHalfHAgo,
+			}),
+			insert.Verdict(realm, tID1, vh, "build-0", internal.VerdictStatus_EXPECTED, twoAndHalfHAgo, map[string]interface{}{
+				"IngestionTime":         oneAndHalfHAgo,
+				"UnexpectedResultCount": 0,
+				"TotalResultCount":      1,
+			}),
+			insert.Verdict(realm, tID1, vh, "build-1", internal.VerdictStatus_VERDICT_FLAKY, twoAndHalfHAgo, map[string]interface{}{
+				"IngestionTime":         halfHAgo,
+				"UnexpectedResultCount": 1,
+				"TotalResultCount":      2,
+			}),
+			insert.Verdict(realm, tID1, vh, "build-2", internal.VerdictStatus_EXPECTED, oneAndHalfHAgo, map[string]interface{}{
+				"IngestionTime":         halfHAgo,
+				"UnexpectedResultCount": 0,
+				"TotalResultCount":      1,
+			}),
+			insert.Verdict(realm, tID2, vh, "build-2", internal.VerdictStatus_VERDICT_FLAKY, oneAndHalfHAgo, map[string]interface{}{
+				"IngestionTime":         halfHAgo,
+				"UnexpectedResultCount": 1,
+				"TotalResultCount":      2,
+			}),
+			insert.Verdict(realm, tID5, vh, "build-1", internal.VerdictStatus_EXPECTED, twoAndHalfHAgo, map[string]interface{}{
+				"IngestionTime":         now.Add(-45 * time.Minute),
+				"UnexpectedResultCount": 0,
+				"TotalResultCount":      1,
+			}),
+			insert.Verdict(realm, tID5, vh, "build-2", internal.VerdictStatus_VERDICT_FLAKY, oneAndHalfHAgo, map[string]interface{}{
+				"IngestionTime":         halfHAgo,
+				"UnexpectedResultCount": 1,
+				"TotalResultCount":      2,
+			}),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		verdicts := map[string]map[string]*bqpb.Verdict{
+			tID1: {
+				"build-0": {
+					Invocation: "build-0",
+					Status:     "EXPECTED",
+					CreateTime: timestamppb.New(twoAndHalfHAgo),
+				},
+				"build-1": {
+					Invocation: "build-1",
+					Status:     "VERDICT_FLAKY",
+					CreateTime: timestamppb.New(twoAndHalfHAgo),
+				},
+				"build-2": {
+					Invocation: "build-2",
+					Status:     "EXPECTED",
+					CreateTime: timestamppb.New(oneAndHalfHAgo),
+				},
+			},
+			tID2: {
+				"build-2": {
+					Invocation: "build-2",
+					Status:     "VERDICT_FLAKY",
+					CreateTime: timestamppb.New(oneAndHalfHAgo),
+				},
+			},
+			tID5: {
+				"build-1": {
+					Invocation: "build-1",
+					Status:     "EXPECTED",
+					CreateTime: timestamppb.New(twoAndHalfHAgo),
+				},
+				"build-2": {
+					Invocation: "build-2",
+					Status:     "VERDICT_FLAKY",
+					CreateTime: timestamppb.New(oneAndHalfHAgo),
+				},
+			},
+		}
+
+		op := &Options{
+			Realm:        realm,
+			CloudProject: "cloud_project",
+			Dataset:      "dataset",
+			Table:        "table",
+			TimeRange: &pb.TimeRange{
+				Earliest: timestamppb.New(start),
+				Latest:   timestamppb.New(now),
+			},
+		}
+		br := CreateBQExporter(op)
+
+		// To check when encountering an error, the test can run to the end
+		// without hanging, or race detector does not detect anything.
+		Convey(`insert fail`, func() {
+			err := br.exportTestVariantRows(ctx, &mockFailInserter{})
+			So(err, ShouldErrLike, "some error")
+		})
+
+		simplifyF := func(rows []*bqpb.TestVariantRow) []*bqpb.TestVariantRow {
+			simpleRows := make([]*bqpb.TestVariantRow, len(rows))
+			for i, r := range rows {
+				simpleRows[i] = &bqpb.TestVariantRow{
+					TestId:          r.TestId,
+					Status:          r.Status,
+					TimeRange:       r.TimeRange,
+					FlakeStatistics: r.FlakeStatistics,
+					Verdicts:        r.Verdicts,
+				}
+			}
+			return simpleRows
+		}
+
+		sortF := func(rows []*bqpb.TestVariantRow) {
+			sort.Slice(rows, func(i, j int) bool {
+				switch {
+				case rows[i].TestId != rows[j].TestId:
+					return rows[i].TestId < rows[j].TestId
+				default:
+					earliestI, _ := pbutil.AsTime(rows[i].TimeRange.Earliest)
+					earliestJ, _ := pbutil.AsTime(rows[j].TimeRange.Earliest)
+					return earliestI.Before(earliestJ)
+				}
+			})
+		}
+
+		test := func(predicate *atvpb.Predicate, expRows []*bqpb.TestVariantRow) {
+			op.Predicate = predicate
+			ins := &mockPassInserter{}
+			err := br.exportTestVariantRows(ctx, ins)
+			So(err, ShouldBeNil)
+
+			rows := make([]*bqpb.TestVariantRow, len(ins.insertedMessages))
+			for i, m := range ins.insertedMessages {
+				rows[i] = m.Message.(*bqpb.TestVariantRow)
+			}
+			rows = simplifyF(rows)
+			sortF(rows)
+			sortF(expRows)
+			So(rows, ShouldResembleProto, expRows)
+		}
+
+		Convey(`no predicate`, func() {
+			expRows := []*bqpb.TestVariantRow{
+				{
+					TestId: tID1,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      0.5,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     2,
+						UnexpectedResultRate:  float32(1) / 3,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      3,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID1]["build-2"],
+						verdicts[tID1]["build-1"],
+					},
+				},
+				{
+					TestId: tID4,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status:          "CONSISTENTLY_UNEXPECTED",
+					FlakeStatistics: zeroFlakyStatistics(),
+				},
+				{
+					TestId: tID5,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(halfHAgo),
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      1.0,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     1,
+						UnexpectedResultRate:  0.5,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      2,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID5]["build-2"],
+					},
+				},
+				{
+					TestId: tID5,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(m46Ago),
+						Latest:   timestamppb.New(halfHAgo),
+					},
+					Status: "CONSISTENTLY_EXPECTED",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      0.0,
+						FlakyVerdictCount:     0,
+						TotalVerdictCount:     1,
+						UnexpectedResultRate:  0.0,
+						UnexpectedResultCount: 0,
+						TotalResultCount:      1,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID5]["build-1"],
+					},
+				},
+				{
+					TestId: tID5,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   timestamppb.New(m46Ago),
+					},
+					Status:          "FLAKY",
+					FlakeStatistics: zeroFlakyStatistics(),
+				},
+				{
+					TestId: tID2,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(halfHAgo),
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      1.0,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     1,
+						UnexpectedResultRate:  0.5,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      2,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID2]["build-2"],
+					},
+				},
+				{
+					TestId: tID6,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status:          "CONSISTENTLY_EXPECTED",
+					FlakeStatistics: zeroFlakyStatistics(),
+				},
+				{
+					TestId: tID3,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status:          "FLAKY",
+					FlakeStatistics: zeroFlakyStatistics(),
+				},
+			}
+			test(nil, expRows)
+		})
+
+		Convey(`status predicate`, func() {
+			predicate := &atvpb.Predicate{
+				Status: atvpb.Status_FLAKY,
+			}
+
+			expRows := []*bqpb.TestVariantRow{
+				{
+					TestId: tID2,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(halfHAgo),
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      1.0,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     1,
+						UnexpectedResultRate:  0.5,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      2,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID2]["build-2"],
+					},
+				},
+				{
+					TestId: tID1,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      0.5,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     2,
+						UnexpectedResultRate:  float32(1) / 3,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      3,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID1]["build-2"],
+						verdicts[tID1]["build-1"],
+					},
+				},
+				{
+					TestId: tID3,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status:          "FLAKY",
+					FlakeStatistics: zeroFlakyStatistics(),
+				},
+				{
+					TestId: tID5,
+					TimeRange: &pb.TimeRange{
+						Earliest: timestamppb.New(halfHAgo),
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      1.0,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     1,
+						UnexpectedResultRate:  0.5,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      2,
+					},
+					Verdicts: []*bqpb.Verdict{
+						{
+							Invocation: "build-2",
+							Status:     "VERDICT_FLAKY",
+							CreateTime: timestamppb.New(oneAndHalfHAgo),
+						},
+					},
+				},
+				{
+					TestId: tID5,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   timestamppb.New(m46Ago),
+					},
+					Status:          "FLAKY",
+					FlakeStatistics: zeroFlakyStatistics(),
+				},
+			}
+
+			test(predicate, expRows)
+		})
+
+		Convey(`testIdRegexp`, func() {
+			predicate := &atvpb.Predicate{
+				TestIdRegexp: tID1,
+			}
+			expRows := []*bqpb.TestVariantRow{
+				{
+					TestId: tID1,
+					TimeRange: &pb.TimeRange{
+						Earliest: op.TimeRange.Earliest,
+						Latest:   op.TimeRange.Latest,
+					},
+					Status: "FLAKY",
+					FlakeStatistics: &atvpb.FlakeStatistics{
+						FlakyVerdictRate:      0.5,
+						FlakyVerdictCount:     1,
+						TotalVerdictCount:     2,
+						UnexpectedResultRate:  float32(1) / 3,
+						UnexpectedResultCount: 1,
+						TotalResultCount:      3,
+					},
+					Verdicts: []*bqpb.Verdict{
+						verdicts[tID1]["build-2"],
+						verdicts[tID1]["build-1"],
+					},
+				},
+			}
+
+			test(predicate, expRows)
+		})
+
+		variantExpRows := []*bqpb.TestVariantRow{
+			{
+				TestId: tID6,
+				TimeRange: &pb.TimeRange{
+					Earliest: op.TimeRange.Earliest,
+					Latest:   op.TimeRange.Latest,
+				},
+				Status:          "CONSISTENTLY_EXPECTED",
+				FlakeStatistics: zeroFlakyStatistics(),
+			},
+		}
+		Convey(`variantEqual`, func() {
+			predicate := &atvpb.Predicate{
+				Variant: &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Equals{
+						Equals: pbutil.Variant("a", "b"),
+					},
+				},
+			}
+			test(predicate, variantExpRows)
+		})
+
+		Convey(`variantHashEqual`, func() {
+			predicate := &atvpb.Predicate{
+				Variant: &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_HashEquals{
+						HashEquals: pbutil.VariantHash(pbutil.Variant("a", "b")),
+					},
+				},
+			}
+			test(predicate, variantExpRows)
+		})
+
+		Convey(`variantContain`, func() {
+			predicate := &atvpb.Predicate{
+				Variant: &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("a", "b"),
+					},
+				},
+			}
+			test(predicate, variantExpRows)
+		})
+	})
+}
diff --git a/analysis/internal/services/testvariantbqexporter/main_test.go b/analysis/internal/services/testvariantbqexporter/main_test.go
new file mode 100644
index 0000000..0ccaf9b
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/services/testvariantbqexporter/schema.go b/analysis/internal/services/testvariantbqexporter/schema.go
new file mode 100644
index 0000000..027e4ce
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/schema.go
@@ -0,0 +1,64 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	"github.com/golang/protobuf/descriptor"
+	desc "github.com/golang/protobuf/protoc-gen-go/descriptor"
+
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const rowMessage = "weetbix.bq.TestVariantRow"
+
+const partitionExpirationTime = 540 * 24 * time.Hour
+
+var tableMetadata *bigquery.TableMetadata
+
+func init() {
+	var err error
+	var schema bigquery.Schema
+	if schema, err = generateRowSchema(); err != nil {
+		panic(err)
+	}
+	tableMetadata = &bigquery.TableMetadata{
+		TimePartitioning: &bigquery.TimePartitioning{
+			Type:       bigquery.DayPartitioningType,
+			Expiration: partitionExpirationTime,
+			Field:      "partition_time",
+		},
+		Clustering: &bigquery.Clustering{
+			Fields: []string{"partition_time", "realm", "test_id", "variant_hash"},
+		},
+		// Relax ensures no fields are marked "required".
+		Schema: schema.Relax(),
+	}
+}
+
+func generateRowSchema() (schema bigquery.Schema, err error) {
+	fd, _ := descriptor.MessageDescriptorProto(&bqpb.TestVariantRow{})
+	fdfs, _ := descriptor.MessageDescriptorProto(&atvpb.FlakeStatistics{})
+	fdsp, _ := descriptor.MessageDescriptorProto(&pb.StringPair{})
+	fdtmd, _ := descriptor.MessageDescriptorProto(&pb.TestMetadata{})
+	fdtr, _ := descriptor.MessageDescriptorProto(&pb.TimeRange{})
+	fdset := &desc.FileDescriptorSet{File: []*desc.FileDescriptorProto{fd, fdfs, fdsp, fdtmd, fdtr}}
+	return bqutil.GenerateSchema(fdset, rowMessage)
+}
diff --git a/analysis/internal/services/testvariantbqexporter/schema_test.go b/analysis/internal/services/testvariantbqexporter/schema_test.go
new file mode 100644
index 0000000..0739dfb
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/schema_test.go
@@ -0,0 +1,41 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestSchema(t *testing.T) {
+	t.Parallel()
+	Convey(`With Schema`, t, func() {
+		var fieldNames []string
+		for _, field := range tableMetadata.Schema {
+			fieldNames = append(fieldNames, field.Name)
+		}
+		Convey(`Time partitioning field is defined`, func() {
+			for _, clusteringField := range tableMetadata.Clustering.Fields {
+				So(clusteringField, ShouldBeIn, fieldNames)
+			}
+		})
+		Convey(`Clustering fields are defined`, func() {
+			for _, clusteringField := range tableMetadata.Clustering.Fields {
+				So(clusteringField, ShouldBeIn, fieldNames)
+			}
+		})
+	})
+}
diff --git a/analysis/internal/services/testvariantbqexporter/task.go b/analysis/internal/services/testvariantbqexporter/task.go
new file mode 100644
index 0000000..638172c
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/task.go
@@ -0,0 +1,130 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"time"
+
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/auth/realms"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const (
+	taskClass = "export-test-variants"
+	queue     = "export-test-variants"
+	// BqExportJobInterval is the interval between two bq export cron jobs.
+	// It's also used as the default time range of each row.
+	BqExportJobInterval = time.Hour
+)
+
+// RegisterTaskClass registers the task class for tq dispatcher.
+func RegisterTaskClass() {
+	tq.RegisterTaskClass(tq.TaskClass{
+		ID:        taskClass,
+		Prototype: &taskspb.ExportTestVariants{},
+		Queue:     queue,
+		Kind:      tq.NonTransactional,
+		Handler: func(ctx context.Context, payload proto.Message) error {
+			task := payload.(*taskspb.ExportTestVariants)
+			br := CreateBQExporter(&Options{
+				Realm:        task.Realm,
+				CloudProject: task.CloudProject,
+				Dataset:      task.Dataset,
+				Table:        task.Table,
+				Predicate:    task.Predicate,
+				TimeRange:    task.TimeRange,
+			})
+			return br.ExportRows(ctx)
+		},
+	})
+}
+
+// Schedule enqueues a task to export AnalyzedTestVariant rows to BigQuery.
+func Schedule(ctx context.Context, realm, cloudProject, dataset, table string, predicate *atvpb.Predicate, timeRange *pb.TimeRange) error {
+	earliest, err := pbutil.AsTime(timeRange.Earliest)
+	if err != nil {
+		return err
+	}
+	key := fmt.Sprintf("%s-%s-%s-%s-%d", realm, cloudProject, dataset, url.PathEscape(table), earliest.Unix())
+	return tq.AddTask(ctx, &tq.Task{
+		Title: key,
+		Payload: &taskspb.ExportTestVariants{
+			Realm:        realm,
+			CloudProject: cloudProject,
+			Dataset:      dataset,
+			Table:        table,
+			Predicate:    predicate,
+			TimeRange:    timeRange,
+		},
+		DeduplicationKey: key,
+	})
+}
+
+// ScheduleTasks schedules tasks to export test variants to BigQuery.
+// It schedules a task per realm per table.
+func ScheduleTasks(ctx context.Context) error {
+	pjcs, err := config.Projects(ctx)
+	if err != nil {
+		return errors.Annotate(err, "get project configs").Err()
+	}
+
+	// The cron job is scheduled to run at 0:00, 1:00 ..., and to export rows
+	// containing data of the past hour.
+	// In case this is a retry, round the time back to the full hour.
+	latest := clock.Now(ctx).UTC().Truncate(time.Hour)
+	if err != nil {
+		return err
+	}
+	timeRange := &pb.TimeRange{
+		Earliest: timestamppb.New(latest.Add(-BqExportJobInterval)),
+		Latest:   timestamppb.New(latest),
+	}
+
+	var errs []error
+	for pj, cg := range pjcs {
+		for _, rc := range cg.GetRealms() {
+			fullRealm := realms.Join(pj, rc.Name)
+			bqcs := rc.GetTestVariantAnalysis().GetBqExports()
+			for _, bqc := range bqcs {
+				table := bqc.GetTable()
+				if table == nil {
+					continue
+				}
+				err := Schedule(ctx, fullRealm, table.CloudProject, table.Dataset, table.Table, bqc.GetPredicate(), timeRange)
+				if err != nil {
+					errs = append(errs, err)
+				}
+			}
+		}
+	}
+	if len(errs) > 0 {
+		return errors.NewMultiError(errs...)
+	}
+	return nil
+}
diff --git a/analysis/internal/services/testvariantbqexporter/task_test.go b/analysis/internal/services/testvariantbqexporter/task_test.go
new file mode 100644
index 0000000..ad37622
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/task_test.go
@@ -0,0 +1,140 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"testing"
+	"time"
+
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func init() {
+	RegisterTaskClass()
+}
+
+func TestSchedule(t *testing.T) {
+	Convey(`TestSchedule`, t, func() {
+		ctx, skdr := tq.TestingContext(testutil.TestingContext(), nil)
+
+		realm := "realm"
+		cloudProject := "cloudProject"
+		dataset := "dataset"
+		table := "table"
+		predicate := &atvpb.Predicate{
+			Status: atvpb.Status_FLAKY,
+		}
+		now := clock.Now(ctx)
+		timeRange := &pb.TimeRange{
+			Earliest: timestamppb.New(now.Add(-time.Hour)),
+			Latest:   timestamppb.New(now),
+		}
+		task := &taskspb.ExportTestVariants{
+			Realm:        realm,
+			CloudProject: cloudProject,
+			Dataset:      dataset,
+			Table:        table,
+			Predicate:    predicate,
+			TimeRange:    timeRange,
+		}
+		So(Schedule(ctx, realm, cloudProject, dataset, table, predicate, timeRange), ShouldBeNil)
+		So(skdr.Tasks().Payloads()[0], ShouldResembleProto, task)
+	})
+}
+
+func createProjectsConfig() map[string]*configpb.ProjectConfig {
+	return map[string]*configpb.ProjectConfig{
+		"chromium": {
+			Realms: []*configpb.RealmConfig{
+				{
+					Name: "ci",
+					TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+						BqExports: []*configpb.BigQueryExport{
+							{
+								Table: &configpb.BigQueryExport_BigQueryTable{
+									CloudProject: "test-hrd",
+									Dataset:      "chromium",
+									Table:        "flaky_test_variants_ci",
+								},
+							},
+							{
+								Table: &configpb.BigQueryExport_BigQueryTable{
+									CloudProject: "test-hrd",
+									Dataset:      "chromium",
+									Table:        "flaky_test_variants_ci_copy",
+								},
+							},
+						},
+					},
+				},
+				{
+					Name: "try",
+					TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+						BqExports: []*configpb.BigQueryExport{
+							{
+								Table: &configpb.BigQueryExport_BigQueryTable{
+									CloudProject: "test-hrd",
+									Dataset:      "chromium",
+									Table:        "flaky_test_variants_try",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+		"project_no_realms": {
+			BugFilingThreshold: &configpb.ImpactThreshold{
+				TestResultsFailed: &configpb.MetricThreshold{
+					OneDay: proto.Int64(1000),
+				},
+			},
+		},
+		"project_no_bq": {
+			Realms: []*configpb.RealmConfig{
+				{
+					Name: "ci",
+				},
+			},
+		},
+	}
+}
+
+func TestScheduleTasks(t *testing.T) {
+	Convey(`TestScheduleTasks`, t, func() {
+		ctx, skdr := tq.TestingContext(testutil.TestingContext(), nil)
+		ctx = memory.Use(ctx)
+		config.SetTestProjectConfig(ctx, createProjectsConfig())
+
+		err := ScheduleTasks(ctx)
+		So(err, ShouldBeNil)
+		So(len(skdr.Tasks().Payloads()), ShouldEqual, 3)
+	})
+}
diff --git a/analysis/internal/services/testvariantbqexporter/test_variant_row.go b/analysis/internal/services/testvariantbqexporter/test_variant_row.go
new file mode 100644
index 0000000..4cc09c0
--- /dev/null
+++ b/analysis/internal/services/testvariantbqexporter/test_variant_row.go
@@ -0,0 +1,594 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantbqexporter
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"sort"
+	"strconv"
+	"strings"
+	"text/template"
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	"cloud.google.com/go/spanner"
+	"golang.org/x/sync/errgroup"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/common/bq"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/bqutil"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	bqpb "go.chromium.org/luci/analysis/proto/bq"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func testVariantName(realm, testID, variantHash string) string {
+	return fmt.Sprintf("realms/%s/tests/%s/variants/%s", realm, url.PathEscape(testID), variantHash)
+}
+
+func (b *BQExporter) populateQueryParameters() (inputs, params map[string]interface{}, err error) {
+	inputs = map[string]interface{}{
+		"TestIdFilter": b.options.Predicate.GetTestIdRegexp() != "",
+		"StatusFilter": b.options.Predicate.GetStatus() != atvpb.Status_STATUS_UNSPECIFIED,
+	}
+
+	params = map[string]interface{}{
+		"realm":              b.options.Realm,
+		"flakyVerdictStatus": int(internal.VerdictStatus_VERDICT_FLAKY),
+	}
+
+	st, err := pbutil.AsTime(b.options.TimeRange.GetEarliest())
+	if err != nil {
+		return nil, nil, err
+	}
+	params["startTime"] = st
+
+	et, err := pbutil.AsTime(b.options.TimeRange.GetLatest())
+	if err != nil {
+		return nil, nil, err
+	}
+	params["endTime"] = et
+
+	if re := b.options.Predicate.GetTestIdRegexp(); re != "" && re != ".*" {
+		params["testIdRegexp"] = fmt.Sprintf("^%s$", re)
+	}
+
+	if status := b.options.Predicate.GetStatus(); status != atvpb.Status_STATUS_UNSPECIFIED {
+		params["status"] = int(status)
+	}
+
+	switch p := b.options.Predicate.GetVariant().GetPredicate().(type) {
+	case *pb.VariantPredicate_Equals:
+		inputs["VariantHashEquals"] = true
+		params["variantHashEquals"] = pbutil.VariantHash(p.Equals)
+	case *pb.VariantPredicate_HashEquals:
+		inputs["VariantHashEquals"] = true
+		params["variantHashEquals"] = p.HashEquals
+	case *pb.VariantPredicate_Contains:
+		if len(p.Contains.Def) > 0 {
+			inputs["VariantContains"] = true
+			params["variantContains"] = pbutil.VariantToStrings(p.Contains)
+		}
+	case nil:
+		// No filter.
+	default:
+		return nil, nil, errors.Reason("unexpected variant predicate %q", p).Err()
+	}
+	return
+}
+
+type result struct {
+	UnexpectedResultCount spanner.NullInt64
+	TotalResultCount      spanner.NullInt64
+	FlakyVerdictCount     spanner.NullInt64
+	TotalVerdictCount     spanner.NullInt64
+	Invocations           []string
+}
+
+type statusAndTimeRange struct {
+	status atvpb.Status
+	tr     *pb.TimeRange
+}
+
+// timeRanges splits the exported time range into the distinct time ranges to be
+// exported, corresponding to the different statuses the test had during the
+// exported time range. Only the time ranges which had a status meeting the
+// criteria to be exported are returned
+//
+// It checks if it's needed to narrow/split the original time range
+// based on the test variant's status update history.
+// For example if the original time range is [10:00, 11:00) and a test variant's
+// status had the following updates:
+//   - 9:30: CONSISTENTLY_EXPECTED -> FLAKY
+//   - 10:10: FLAKY -> CONSISTENTLY_EXPECTED
+//   - 10:20: CONSISTENTLY_EXPECTED -> FLAKY
+//   - 10:30: FLAKY -> CONSISTENTLY_EXPECTED
+//   - 10:50: CONSISTENTLY_EXPECTED -> FLAKY
+// If b.options.Predicate.Status = FLAKY, the timeRanges will be
+// [10:00, 10:10), [10:20, 10:30) and [10:50, 11:00).
+// If b.options.Predicate.Status is not specified, the timeRanges will be
+// [10:00, 10:10), [10:10, 10:20),  [10:20, 10:30), [10:20, 10:50) and [10:50, 11:00).
+func (b *BQExporter) timeRanges(currentStatus atvpb.Status, statusUpdateTime spanner.NullTime, previousStatuses []atvpb.Status, previousUpdateTimes []time.Time) []statusAndTimeRange {
+	if !statusUpdateTime.Valid {
+		panic("Empty Status Update time")
+	}
+
+	// The timestamps have been verified in populateQueryParameters.
+	exportStart, _ := pbutil.AsTime(b.options.TimeRange.Earliest)
+	exportEnd, _ := pbutil.AsTime(b.options.TimeRange.Latest)
+
+	exportStatus := b.options.Predicate.GetStatus()
+	previousStatuses = append([]atvpb.Status{currentStatus}, previousStatuses...)
+	previousUpdateTimes = append([]time.Time{statusUpdateTime.Time}, previousUpdateTimes...)
+
+	var ranges []statusAndTimeRange
+	rangeEnd := exportEnd
+	// Iterate through status updates, from latest to earliest.
+	for i, updateTime := range previousUpdateTimes {
+		if updateTime.After(exportEnd) {
+			continue
+		}
+
+		s := previousStatuses[i]
+		shouldExport := s == exportStatus || exportStatus == atvpb.Status_STATUS_UNSPECIFIED
+		if shouldExport {
+			if updateTime.After(exportStart) {
+				ranges = append(ranges, statusAndTimeRange{status: s, tr: &pb.TimeRange{
+					Earliest: pbutil.MustTimestampProto(updateTime),
+					Latest:   pbutil.MustTimestampProto(rangeEnd),
+				}})
+			} else {
+				ranges = append(ranges, statusAndTimeRange{status: s, tr: &pb.TimeRange{
+					Earliest: b.options.TimeRange.Earliest,
+					Latest:   pbutil.MustTimestampProto(rangeEnd),
+				}})
+			}
+		}
+		rangeEnd = updateTime
+
+		if !updateTime.After(exportStart) {
+			break
+		}
+	}
+	return ranges
+}
+
+type verdictInfo struct {
+	verdict               *bqpb.Verdict
+	ingestionTime         time.Time
+	unexpectedResultCount int
+	totalResultCount      int
+}
+
+// convertVerdicts converts strings to verdictInfos.
+// Ordered by IngestionTime.
+func (b *BQExporter) convertVerdicts(vs []string) ([]verdictInfo, error) {
+	vis := make([]verdictInfo, 0, len(vs))
+	for _, v := range vs {
+		parts := strings.Split(v, "/")
+		if len(parts) != 6 {
+			return nil, fmt.Errorf("verdict %s in wrong format", v)
+		}
+		verdict := &bqpb.Verdict{
+			Invocation: parts[0],
+		}
+		s, err := strconv.Atoi(parts[1])
+		if err != nil {
+			return nil, err
+		}
+		verdict.Status = internal.VerdictStatus(s).String()
+
+		ct, err := time.Parse(time.RFC3339Nano, parts[2])
+		if err != nil {
+			return nil, err
+		}
+		verdict.CreateTime = timestamppb.New(ct)
+
+		it, err := time.Parse(time.RFC3339Nano, parts[3])
+		if err != nil {
+			return nil, err
+		}
+
+		unexpectedResultCount, err := strconv.Atoi(parts[4])
+		if err != nil {
+			return nil, err
+		}
+
+		totalResultCount, err := strconv.Atoi(parts[5])
+		if err != nil {
+			return nil, err
+		}
+
+		vis = append(vis, verdictInfo{
+			verdict:               verdict,
+			ingestionTime:         it,
+			unexpectedResultCount: unexpectedResultCount,
+			totalResultCount:      totalResultCount,
+		})
+	}
+
+	sort.Slice(vis, func(i, j int) bool { return vis[i].ingestionTime.Before(vis[j].ingestionTime) })
+
+	return vis, nil
+}
+
+func (b *BQExporter) populateVerdictsInRange(tv *bqpb.TestVariantRow, vs []verdictInfo, tr *pb.TimeRange) {
+	earliest, _ := pbutil.AsTime(tr.Earliest)
+	latest, _ := pbutil.AsTime(tr.Latest)
+	var vsInRange []*bqpb.Verdict
+	for _, v := range vs {
+		if (v.ingestionTime.After(earliest) || v.ingestionTime.Equal(earliest)) && v.ingestionTime.Before(latest) {
+			vsInRange = append(vsInRange, v.verdict)
+		}
+	}
+	tv.Verdicts = vsInRange
+}
+
+func zeroFlakyStatistics() *atvpb.FlakeStatistics {
+	return &atvpb.FlakeStatistics{
+		FlakyVerdictCount:     0,
+		TotalVerdictCount:     0,
+		FlakyVerdictRate:      float32(0),
+		UnexpectedResultCount: 0,
+		TotalResultCount:      0,
+		UnexpectedResultRate:  float32(0),
+	}
+}
+
+func (b *BQExporter) populateFlakeStatistics(tv *bqpb.TestVariantRow, res *result, vs []verdictInfo, tr *pb.TimeRange) {
+	if b.options.TimeRange.Earliest != tr.Earliest || b.options.TimeRange.Latest != tr.Latest {
+		// The time range is different from the original one, so we cannot use the
+		// statistics from query, instead we need to calculate using data from each verdicts.
+		b.populateFlakeStatisticsByVerdicts(tv, vs, tr)
+		return
+	}
+	zero64 := int64(0)
+	if res.TotalResultCount.Valid && res.TotalResultCount.Int64 == zero64 {
+		tv.FlakeStatistics = zeroFlakyStatistics()
+		return
+	}
+	tv.FlakeStatistics = &atvpb.FlakeStatistics{
+		FlakyVerdictCount:     res.FlakyVerdictCount.Int64,
+		TotalVerdictCount:     res.TotalVerdictCount.Int64,
+		FlakyVerdictRate:      float32(res.FlakyVerdictCount.Int64) / float32(res.TotalVerdictCount.Int64),
+		UnexpectedResultCount: res.UnexpectedResultCount.Int64,
+		TotalResultCount:      res.TotalResultCount.Int64,
+		UnexpectedResultRate:  float32(res.UnexpectedResultCount.Int64) / float32(res.TotalResultCount.Int64),
+	}
+}
+
+func (b *BQExporter) populateFlakeStatisticsByVerdicts(tv *bqpb.TestVariantRow, vs []verdictInfo, tr *pb.TimeRange) {
+	if len(vs) == 0 {
+		tv.FlakeStatistics = zeroFlakyStatistics()
+		return
+	}
+
+	earliest, _ := pbutil.AsTime(tr.Earliest)
+	latest, _ := pbutil.AsTime(tr.Latest)
+	flakyVerdicts := 0
+	totalVerdicts := 0
+	unexpectedResults := 0
+	totalResults := 0
+	for _, v := range vs {
+		if (v.ingestionTime.After(earliest) || v.ingestionTime.Equal(earliest)) && v.ingestionTime.Before(latest) {
+			totalVerdicts++
+			unexpectedResults += v.unexpectedResultCount
+			totalResults += v.totalResultCount
+			if v.verdict.Status == internal.VerdictStatus_VERDICT_FLAKY.String() {
+				flakyVerdicts++
+			}
+		}
+	}
+
+	if totalResults == 0 {
+		tv.FlakeStatistics = zeroFlakyStatistics()
+		return
+	}
+
+	tv.FlakeStatistics = &atvpb.FlakeStatistics{
+		FlakyVerdictCount:     int64(flakyVerdicts),
+		TotalVerdictCount:     int64(totalVerdicts),
+		FlakyVerdictRate:      float32(flakyVerdicts) / float32(totalVerdicts),
+		UnexpectedResultCount: int64(unexpectedResults),
+		TotalResultCount:      int64(totalResults),
+		UnexpectedResultRate:  float32(unexpectedResults) / float32(totalResults),
+	}
+}
+
+func deepCopy(tv *bqpb.TestVariantRow) *bqpb.TestVariantRow {
+	return &bqpb.TestVariantRow{
+		Name:         tv.Name,
+		Realm:        tv.Realm,
+		TestId:       tv.TestId,
+		VariantHash:  tv.VariantHash,
+		Variant:      tv.Variant,
+		TestMetadata: tv.TestMetadata,
+		Tags:         tv.Tags,
+	}
+}
+
+// generateTestVariantRows converts a bq.Row to *bqpb.TestVariantRows.
+//
+// For the most cases it should return one row. But if the test variant
+// changes status during the default time range, it may need to export 2 rows
+// for the previous and current statuses with smaller time ranges.
+func (b *BQExporter) generateTestVariantRows(ctx context.Context, row *spanner.Row, bf spanutil.Buffer) ([]*bqpb.TestVariantRow, error) {
+	tv := &bqpb.TestVariantRow{}
+	va := &pb.Variant{}
+	var vs []*result
+	var statusUpdateTime spanner.NullTime
+	var tmd spanutil.Compressed
+	var status atvpb.Status
+	var previousStatuses []atvpb.Status
+	var previousUpdateTimes []time.Time
+	if err := bf.FromSpanner(
+		row,
+		&tv.Realm,
+		&tv.TestId,
+		&tv.VariantHash,
+		&va,
+		&tv.Tags,
+		&tmd,
+		&status,
+		&statusUpdateTime,
+		&previousStatuses,
+		&previousUpdateTimes,
+		&vs,
+	); err != nil {
+		return nil, err
+	}
+
+	tv.Name = testVariantName(tv.Realm, tv.TestId, tv.VariantHash)
+	if len(vs) != 1 {
+		return nil, fmt.Errorf("fail to get verdicts for test variant %s", tv.Name)
+	}
+
+	tv.Variant = pbutil.VariantToStringPairs(va)
+	tv.Status = status.String()
+
+	if len(tmd) > 0 {
+		tv.TestMetadata = &pb.TestMetadata{}
+		if err := proto.Unmarshal(tmd, tv.TestMetadata); err != nil {
+			return nil, errors.Annotate(err, "error unmarshalling test_metadata for %s", tv.Name).Err()
+		}
+	}
+
+	timeRanges := b.timeRanges(status, statusUpdateTime, previousStatuses, previousUpdateTimes)
+	verdicts, err := b.convertVerdicts(vs[0].Invocations)
+	if err != nil {
+		return nil, err
+	}
+
+	var tvs []*bqpb.TestVariantRow
+	for _, str := range timeRanges {
+		newTV := deepCopy(tv)
+		newTV.TimeRange = str.tr
+		newTV.PartitionTime = str.tr.Latest
+		newTV.Status = str.status.String()
+		b.populateFlakeStatistics(newTV, vs[0], verdicts, str.tr)
+		b.populateVerdictsInRange(newTV, verdicts, str.tr)
+		tvs = append(tvs, newTV)
+	}
+
+	return tvs, nil
+}
+
+func (b *BQExporter) query(ctx context.Context, f func(*bqpb.TestVariantRow) error) error {
+	inputs, params, err := b.populateQueryParameters()
+	if err != nil {
+		return err
+	}
+	st, err := spanutil.GenerateStatement(testVariantRowsTmpl, testVariantRowsTmpl.Name(), inputs)
+	if err != nil {
+		return err
+	}
+	st.Params = params
+
+	var bf spanutil.Buffer
+	return span.Query(ctx, st).Do(
+		func(row *spanner.Row) error {
+			tvrs, err := b.generateTestVariantRows(ctx, row, bf)
+			if err != nil {
+				return err
+			}
+			for _, tvr := range tvrs {
+				if err := f(tvr); err != nil {
+					return err
+				}
+			}
+			return nil
+		},
+	)
+}
+
+func (b *BQExporter) queryTestVariantsToExport(ctx context.Context, batchC chan []*bqpb.TestVariantRow) error {
+	ctx, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+
+	tvrs := make([]*bqpb.TestVariantRow, 0, maxBatchRowCount)
+	rowCount := 0
+	err := b.query(ctx, func(tvr *bqpb.TestVariantRow) error {
+		tvrs = append(tvrs, tvr)
+		rowCount++
+		if len(tvrs) >= maxBatchRowCount {
+			select {
+			case <-ctx.Done():
+				return ctx.Err()
+			case batchC <- tvrs:
+			}
+			tvrs = make([]*bqpb.TestVariantRow, 0, maxBatchRowCount)
+		}
+		return nil
+	})
+	if err != nil {
+		return err
+	}
+
+	if len(tvrs) > 0 {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case batchC <- tvrs:
+		}
+	}
+
+	logging.Infof(ctx, "fetched %d rows for exporting %s test variants", rowCount, b.options.Realm)
+	return nil
+}
+
+// inserter is implemented by bigquery.Inserter.
+type inserter interface {
+	// PutWithRetries uploads one or more rows to the BigQuery service.
+	PutWithRetries(ctx context.Context, src []*bq.Row) error
+}
+
+func (b *BQExporter) batchExportRows(ctx context.Context, ins inserter, batchC chan []*bqpb.TestVariantRow) error {
+	eg, ctx := errgroup.WithContext(ctx)
+	defer eg.Wait()
+
+	for rows := range batchC {
+		rows := rows
+		if err := b.batchSem.Acquire(ctx, 1); err != nil {
+			return err
+		}
+
+		eg.Go(func() error {
+			defer b.batchSem.Release(1)
+			err := b.insertRows(ctx, ins, rows)
+			if bqutil.FatalError(err) {
+				err = tq.Fatal.Apply(err)
+			}
+			return err
+		})
+	}
+
+	return eg.Wait()
+}
+
+// insertRows inserts rows into BigQuery.
+// Retries on transient errors.
+func (b *BQExporter) insertRows(ctx context.Context, ins inserter, rowProtos []*bqpb.TestVariantRow) error {
+	if err := b.putLimiter.Wait(ctx); err != nil {
+		return err
+	}
+
+	rows := make([]*bq.Row, 0, len(rowProtos))
+	for _, ri := range rowProtos {
+		row := &bq.Row{
+			Message:  ri,
+			InsertID: bigquery.NoDedupeID,
+		}
+		rows = append(rows, row)
+	}
+
+	return ins.PutWithRetries(ctx, rows)
+}
+
+func (b *BQExporter) exportTestVariantRows(ctx context.Context, ins inserter) error {
+	batchC := make(chan []*bqpb.TestVariantRow)
+	eg, ctx := errgroup.WithContext(ctx)
+
+	eg.Go(func() error {
+		return b.batchExportRows(ctx, ins, batchC)
+	})
+
+	eg.Go(func() error {
+		defer close(batchC)
+		return b.queryTestVariantsToExport(ctx, batchC)
+	})
+
+	return eg.Wait()
+}
+
+var testVariantRowsTmpl = template.Must(template.New("testVariantRowsTmpl").Parse(`
+	@{USE_ADDITIONAL_PARALLELISM=TRUE}
+	WITH test_variants AS (
+		SELECT
+			Realm,
+			TestId,
+			VariantHash,
+		FROM AnalyzedTestVariants
+		WHERE Realm = @realm
+		{{/* Filter by TestId */}}
+		{{if .TestIdFilter}}
+			AND REGEXP_CONTAINS(TestId, @testIdRegexp)
+		{{end}}
+		{{/* Filter by Variant */}}
+		{{if .VariantHashEquals}}
+			AND VariantHash = @variantHashEquals
+		{{end}}
+		{{if .VariantContains }}
+			AND (SELECT LOGICAL_AND(kv IN UNNEST(Variant)) FROM UNNEST(@variantContains) kv)
+		{{end}}
+		{{/* Filter by status */}}
+		{{if .StatusFilter}}
+			AND (
+				(Status = @status AND StatusUpdateTime < @endTime)
+				-- Status updated within the time range, we need to check if the previous
+				-- status(es) satisfies the filter.
+				OR StatusUpdateTime > @startTime
+			)
+		{{end}}
+	)
+
+	SELECT
+		Realm,
+		TestId,
+		VariantHash,
+		Variant,
+		Tags,
+		TestMetadata,
+		Status,
+		StatusUpdateTime,
+		PreviousStatuses,
+		PreviousStatusUpdateTimes,
+		ARRAY(
+		SELECT
+			AS STRUCT SUM(UnexpectedResultCount) UnexpectedResultCount,
+			SUM(TotalResultCount) TotalResultCount,
+			COUNTIF(Status=30) FlakyVerdictCount,
+			COUNT(*) TotalVerdictCount,
+			-- Using struct here will trigger the "null-valued array of struct" query shape
+			-- which is not supported by Spanner.
+			-- Use a string to work around it.
+			ARRAY_AGG(FORMAT('%s/%d/%s/%s/%d/%d', InvocationId, Status, FORMAT_TIMESTAMP("%FT%H:%M:%E*S%Ez", InvocationCreationTime), FORMAT_TIMESTAMP("%FT%H:%M:%E*S%Ez", IngestionTime), UnexpectedResultCount, TotalResultCount)) Invocations
+		FROM
+			Verdicts
+		WHERE
+			Verdicts.Realm = test_variants.Realm
+			AND Verdicts.TestId=test_variants.TestId
+			AND Verdicts.VariantHash=test_variants.VariantHash
+			AND IngestionTime >= @startTime
+			AND IngestionTime < @endTime ) Results
+	FROM
+		test_variants
+	JOIN
+		AnalyzedTestVariants
+	USING
+		(Realm,
+			TestId,
+			VariantHash)
+`))
diff --git a/analysis/internal/services/testvariantupdator/main_test.go b/analysis/internal/services/testvariantupdator/main_test.go
new file mode 100644
index 0000000..bd45204
--- /dev/null
+++ b/analysis/internal/services/testvariantupdator/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantupdator
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/services/testvariantupdator/update_test_variant.go b/analysis/internal/services/testvariantupdator/update_test_variant.go
new file mode 100644
index 0000000..eb38bf0
--- /dev/null
+++ b/analysis/internal/services/testvariantupdator/update_test_variant.go
@@ -0,0 +1,216 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantupdator
+
+import (
+	"context"
+	"fmt"
+	"net/url"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"google.golang.org/protobuf/proto"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/resultdb/pbutil"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+	_ "go.chromium.org/luci/server/tq/txn/spanner"
+
+	"go.chromium.org/luci/analysis/internal/analyzedtestvariants"
+	"go.chromium.org/luci/analysis/internal/config"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/verdicts"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+)
+
+const (
+	taskClass = "update-test-variant"
+	queue     = "update-test-variant"
+)
+
+// errShouldNotSchedule returned if the AnalyzedTestVariant spanner row
+// does not have timestamp.
+var errShouldNotSchedule = fmt.Errorf("should not schedule update task")
+
+// errUnknownTask is returned if the task has a mismatched timestamp.
+var errUnknownTask = fmt.Errorf("the task is unknown")
+
+// RegisterTaskClass registers the task class for tq dispatcher.
+func RegisterTaskClass() {
+	tq.RegisterTaskClass(tq.TaskClass{
+		ID:        taskClass,
+		Prototype: &taskspb.UpdateTestVariant{},
+		Queue:     queue,
+		Kind:      tq.Transactional,
+		Handler: func(ctx context.Context, payload proto.Message) error {
+			task := payload.(*taskspb.UpdateTestVariant)
+			tvKey := task.TestVariantKey
+			_, err := checkTask(span.Single(ctx), task)
+			switch {
+			case err == errShouldNotSchedule:
+				// Ignore the task.
+				logging.Errorf(ctx, "test variant %s/%s/%s should not have any update task", tvKey.Realm, tvKey.TestId, tvKey.VariantHash)
+				return nil
+			case err == errUnknownTask:
+				// Ignore the task.
+				logging.Errorf(ctx, "unknown task found for test variant %s/%s/%s", tvKey.Realm, tvKey.TestId, tvKey.VariantHash)
+				return nil
+			case err != nil:
+				return err
+			}
+
+			return updateTestVariant(ctx, task)
+		},
+	})
+}
+
+// Schedule enqueues a task to update an AnalyzedTestVariant row.
+func Schedule(ctx context.Context, realm, testID, variantHash string, delay *durationpb.Duration, enqTime time.Time) {
+	tq.MustAddTask(ctx, &tq.Task{
+		Title: fmt.Sprintf("%s-%s-%s", realm, url.PathEscape(testID), variantHash),
+		Payload: &taskspb.UpdateTestVariant{
+			TestVariantKey: &taskspb.TestVariantKey{
+				Realm:       realm,
+				TestId:      testID,
+				VariantHash: variantHash,
+			},
+			EnqueueTime: pbutil.MustTimestampProto(enqTime),
+		},
+		Delay: delay.AsDuration(),
+	})
+}
+
+func configs(ctx context.Context, realm string) (*configpb.UpdateTestVariantTask, error) {
+	rc, err := config.Realm(ctx, realm)
+	switch {
+	case err != nil:
+		return nil, err
+	case rc.GetTestVariantAnalysis().GetUpdateTestVariantTask() == nil:
+		return nil, fmt.Errorf("no UpdateTestVariantTask config found for realm %s", realm)
+	case rc.TestVariantAnalysis.UpdateTestVariantTask.GetUpdateTestVariantTaskInterval() == nil:
+		return nil, fmt.Errorf("no GetUpdateTestVariantTaskInterval config found for realm %s", realm)
+	case rc.TestVariantAnalysis.UpdateTestVariantTask.GetTestVariantStatusUpdateDuration() == nil:
+		return nil, fmt.Errorf("no GetTestVariantStatusUpdateDuration config found for realm %s", realm)
+	default:
+		return rc.TestVariantAnalysis.UpdateTestVariantTask, nil
+	}
+}
+
+// checkTask checks if the task has the same timestamp as the one saved in the
+// row.
+// Task has a mismatched timestamp will be ignored.
+func checkTask(ctx context.Context, task *taskspb.UpdateTestVariant) (*analyzedtestvariants.StatusHistory, error) {
+	statusHistory, enqTime, err := analyzedtestvariants.ReadStatusHistory(ctx, toSpannerKey(task.TestVariantKey))
+	switch {
+	case err != nil:
+		return &analyzedtestvariants.StatusHistory{}, err
+	case enqTime.IsNull():
+		return statusHistory, errShouldNotSchedule
+	case enqTime.Time != pbutil.MustTimestamp(task.EnqueueTime):
+		return statusHistory, errUnknownTask
+	default:
+		return statusHistory, nil
+	}
+}
+
+func updateTestVariant(ctx context.Context, task *taskspb.UpdateTestVariant) error {
+	rc, err := configs(ctx, task.TestVariantKey.Realm)
+	if err != nil {
+		return err
+	}
+	status, err := verdicts.ComputeTestVariantStatusFromVerdicts(span.Single(ctx), task.TestVariantKey, rc.TestVariantStatusUpdateDuration)
+	if err != nil {
+		return err
+	}
+	return updateTestVariantStatus(ctx, task, status)
+}
+
+// updateTestVariantStatus updates the Status and StatusUpdateTime of the
+// AnalyzedTestVariants row if the provided status is different from the one
+// in the row.
+func updateTestVariantStatus(ctx context.Context, task *taskspb.UpdateTestVariant, newStatus atvpb.Status) error {
+	tvKey := task.TestVariantKey
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		// Get the old status, and check the token once again.
+		statusHistory, err := checkTask(ctx, task)
+		if err != nil {
+			return err
+		}
+
+		// Update the Spanner row.
+		vals := map[string]interface{}{
+			"Realm":       tvKey.Realm,
+			"TestId":      tvKey.TestId,
+			"VariantHash": tvKey.VariantHash,
+		}
+		now := clock.Now(ctx)
+
+		oldStatus := statusHistory.Status
+		if oldStatus == newStatus {
+			if newStatus == atvpb.Status_CONSISTENTLY_EXPECTED || newStatus == atvpb.Status_NO_NEW_RESULTS {
+				// This should never happen. But it doesn't have a huge negative impact,
+				// so just log an error and return immediately.
+				logging.Errorf(ctx, "UpdateTestVariant task runs for a test variant without any new unexpected failures: %s/%s/%s", tvKey.Realm, tvKey.TestId, tvKey.VariantHash)
+				return nil
+			}
+			vals["NextUpdateTaskEnqueueTime"] = now
+		} else {
+			vals["Status"] = newStatus
+
+			if statusHistory.PreviousStatuses == nil {
+				vals["PreviousStatuses"] = []atvpb.Status{oldStatus}
+				vals["PreviousStatusUpdateTimes"] = []time.Time{statusHistory.StatusUpdateTime}
+			} else {
+				// "Prepend" the old status and update time so the slices are ordered
+				// by status update time in descending order.
+				// Currently all of the status update records are kept, because we don't
+				// expect to update each test variant's status frequently.
+				// In the future we could consider to remove the old records.
+				vals["PreviousStatuses"] = append([]atvpb.Status{oldStatus}, statusHistory.PreviousStatuses...)
+				vals["PreviousStatusUpdateTimes"] = append([]time.Time{statusHistory.StatusUpdateTime}, statusHistory.PreviousStatusUpdateTimes...)
+			}
+
+			vals["StatusUpdateTime"] = spanner.CommitTimestamp
+			if newStatus != atvpb.Status_CONSISTENTLY_EXPECTED && newStatus != atvpb.Status_NO_NEW_RESULTS {
+				// Only schedule the next UpdateTestVariant task if the test variant
+				// still has unexpected failures.
+				vals["NextUpdateTaskEnqueueTime"] = now
+			}
+		}
+		span.BufferWrite(ctx, spanutil.UpdateMap("AnalyzedTestVariants", vals))
+
+		// Enqueue the next task.
+		if _, ok := vals["NextUpdateTaskEnqueueTime"]; ok {
+			rc, err := configs(ctx, tvKey.Realm)
+			switch {
+			case err != nil:
+				return err
+			default:
+				Schedule(ctx, tvKey.Realm, tvKey.TestId, tvKey.VariantHash, rc.UpdateTestVariantTaskInterval, now)
+			}
+		}
+		return nil
+	})
+	return err
+}
+
+func toSpannerKey(tvKey *taskspb.TestVariantKey) spanner.Key {
+	return spanner.Key{tvKey.Realm, tvKey.TestId, tvKey.VariantHash}
+}
diff --git a/analysis/internal/services/testvariantupdator/update_test_variant_test.go b/analysis/internal/services/testvariantupdator/update_test_variant_test.go
new file mode 100644
index 0000000..f83eb3c
--- /dev/null
+++ b/analysis/internal/services/testvariantupdator/update_test_variant_test.go
@@ -0,0 +1,198 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testvariantupdator
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/resultdb/pbutil"
+	"go.chromium.org/luci/server/span"
+	"go.chromium.org/luci/server/tq"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/analyzedtestvariants"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func init() {
+	RegisterTaskClass()
+}
+
+func TestSchedule(t *testing.T) {
+	Convey(`TestSchedule`, t, func() {
+		ctx, skdr := tq.TestingContext(testutil.SpannerTestContext(t), nil)
+
+		realm := "chromium:ci"
+		testID := "ninja://test"
+		variantHash := "deadbeef"
+		now := clock.Now(ctx)
+		task := &taskspb.UpdateTestVariant{
+			TestVariantKey: &taskspb.TestVariantKey{
+				Realm:       realm,
+				TestId:      testID,
+				VariantHash: variantHash,
+			},
+			EnqueueTime: pbutil.MustTimestampProto(now),
+		}
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			Schedule(ctx, realm, testID, variantHash, durationpb.New(time.Hour), now)
+			return nil
+		})
+		So(err, ShouldBeNil)
+		So(skdr.Tasks().Payloads()[0], ShouldResembleProto, task)
+	})
+}
+
+func TestCheckTask(t *testing.T) {
+	Convey(`checkTask`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		realm := "chromium:ci"
+		tID := "ninja://test"
+		vh := "varianthash"
+		now := clock.Now(ctx)
+		ms := []*spanner.Mutation{
+			insert.AnalyzedTestVariant(realm, tID, vh, atvpb.Status_CONSISTENTLY_EXPECTED,
+				map[string]interface{}{
+					"NextUpdateTaskEnqueueTime": now,
+				}),
+			insert.AnalyzedTestVariant(realm, "anothertest", vh, atvpb.Status_CONSISTENTLY_EXPECTED, nil),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		task := &taskspb.UpdateTestVariant{
+			TestVariantKey: &taskspb.TestVariantKey{
+				Realm:       realm,
+				TestId:      tID,
+				VariantHash: vh,
+			},
+		}
+		Convey(`match`, func() {
+			task.EnqueueTime = pbutil.MustTimestampProto(now)
+			_, err := checkTask(span.Single(ctx), task)
+			So(err, ShouldBeNil)
+		})
+
+		Convey(`mismatch`, func() {
+			anotherTime := now.Add(time.Hour)
+			task.EnqueueTime = pbutil.MustTimestampProto(anotherTime)
+			_, err := checkTask(span.Single(ctx), task)
+			So(err, ShouldEqual, errUnknownTask)
+		})
+
+		Convey(`no schedule`, func() {
+			task.TestVariantKey.TestId = "anothertest"
+			_, err := checkTask(span.Single(ctx), task)
+			So(err, ShouldEqual, errShouldNotSchedule)
+		})
+	})
+}
+
+func createProjectsConfig() map[string]*configpb.ProjectConfig {
+	return map[string]*configpb.ProjectConfig{
+		"chromium": {
+			Realms: []*configpb.RealmConfig{
+				{
+					Name: "ci",
+					TestVariantAnalysis: &configpb.TestVariantAnalysisConfig{
+						UpdateTestVariantTask: &configpb.UpdateTestVariantTask{
+							UpdateTestVariantTaskInterval:   durationpb.New(time.Hour),
+							TestVariantStatusUpdateDuration: durationpb.New(24 * time.Hour),
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func TestUpdateTestVariantStatus(t *testing.T) {
+	Convey(`updateTestVariant`, t, func() {
+		ctx, skdr := tq.TestingContext(testutil.SpannerTestContext(t), nil)
+		ctx = memory.Use(ctx)
+		config.SetTestProjectConfig(ctx, createProjectsConfig())
+		realm := "chromium:ci"
+		vh := "varianthash"
+		now := clock.Now(ctx).UTC()
+		tID1 := "ninja://test1"
+		tID2 := "ninja://test2"
+		statuses := []atvpb.Status{
+			atvpb.Status_FLAKY,
+			atvpb.Status_CONSISTENTLY_UNEXPECTED,
+		}
+		times := []time.Time{
+			now.Add(-24 * time.Hour),
+			now.Add(-240 * time.Hour),
+		}
+		ms := []*spanner.Mutation{
+			insert.AnalyzedTestVariant(realm, tID1, vh, atvpb.Status_CONSISTENTLY_EXPECTED, map[string]interface{}{
+				"NextUpdateTaskEnqueueTime": now,
+				"StatusUpdateTime":          now,
+			}),
+			insert.AnalyzedTestVariant(realm, tID2, vh, atvpb.Status_CONSISTENTLY_EXPECTED, map[string]interface{}{
+				"NextUpdateTaskEnqueueTime": now,
+				"StatusUpdateTime":          now,
+				"PreviousStatuses":          statuses,
+				"PreviousStatusUpdateTimes": times,
+			}),
+			insert.Verdict(realm, tID1, vh, "build-1", internal.VerdictStatus_VERDICT_FLAKY, now.Add(-2*time.Hour), nil),
+			insert.Verdict(realm, tID2, vh, "build-1", internal.VerdictStatus_VERDICT_FLAKY, now.Add(-2*time.Hour), nil),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		test := func(tID string, pStatuses []atvpb.Status, pUpdateTimes []time.Time, i int) {
+			statusHistory, enqTime, err := analyzedtestvariants.ReadStatusHistory(span.Single(ctx), spanner.Key{realm, tID, vh})
+			So(err, ShouldBeNil)
+			statusUpdateTime := statusHistory.StatusUpdateTime
+
+			task := &taskspb.UpdateTestVariant{
+				TestVariantKey: &taskspb.TestVariantKey{
+					Realm:       realm,
+					TestId:      tID,
+					VariantHash: vh,
+				},
+				EnqueueTime: pbutil.MustTimestampProto(now),
+			}
+			err = updateTestVariant(ctx, task)
+			So(err, ShouldBeNil)
+
+			// Read the test variant to confirm the updates.
+			statusHistory, enqTime, err = analyzedtestvariants.ReadStatusHistory(span.Single(ctx), spanner.Key{realm, tID, vh})
+			So(err, ShouldBeNil)
+			So(statusHistory.Status, ShouldEqual, atvpb.Status_FLAKY)
+			So(statusHistory.PreviousStatuses, ShouldResemble, append([]atvpb.Status{atvpb.Status_CONSISTENTLY_EXPECTED}, pStatuses...))
+			So(statusHistory.PreviousStatusUpdateTimes, ShouldResemble, append([]time.Time{statusUpdateTime}, pUpdateTimes...))
+
+			nextTask := skdr.Tasks().Payloads()[i].(*taskspb.UpdateTestVariant)
+			So(pbutil.MustTimestamp(nextTask.EnqueueTime), ShouldEqual, enqTime.Time)
+		}
+
+		test(tID1, []atvpb.Status{}, []time.Time{}, 0)
+		test(tID2, statuses, times, 1)
+	})
+}
diff --git a/analysis/internal/span/buffer.go b/analysis/internal/span/buffer.go
new file mode 100644
index 0000000..32fb929
--- /dev/null
+++ b/analysis/internal/span/buffer.go
@@ -0,0 +1,326 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package span
+
+import (
+	"fmt"
+	"reflect"
+
+	"cloud.google.com/go/spanner"
+	"github.com/golang/protobuf/proto"
+	"go.chromium.org/luci/common/errors"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// Value can be converted to a Spanner value.
+// Typically if type T implements Value, then *T implements Ptr.
+type Value interface {
+	// ToSpanner returns a value of a type supported by Spanner client.
+	ToSpanner() interface{}
+}
+
+// Ptr can be used a destination of reading a Spanner cell.
+// Typically if type *T implements Ptr, then T implements Value.
+type Ptr interface {
+	// SpannerPtr returns to a pointer of a type supported by Spanner client.
+	// SpannerPtr must use one of typed buffers in b.
+	SpannerPtr(b *Buffer) interface{}
+	// FromSpanner replaces Ptr value with the value in the typed buffer returned
+	// by SpannerPtr.
+	FromSpanner(b *Buffer) error
+}
+
+// Buffer can convert a value from a Spanner type to a Go type.
+// Supported types:
+//   - Value and Ptr
+//   - string
+//   - timestamppb.Timestamp
+//   - atvpb.Status
+//   - pb.Variant
+//   - pb.StringPair
+//   - proto.Message
+//   - internal.VerdictStatus
+type Buffer struct {
+	NullString  spanner.NullString
+	NullTime    spanner.NullTime
+	Int64       int64
+	StringSlice []string
+	ByteSlice   []byte
+	Int64Slice  []int64
+}
+
+// FromSpanner is a shortcut for (&Buffer{}).FromSpanner.
+// Appropriate when FromSpanner is called only once, whereas Buffer is reusable
+// throughout function.
+func FromSpanner(row *spanner.Row, ptrs ...interface{}) error {
+	return (&Buffer{}).FromSpanner(row, ptrs...)
+}
+
+// FromSpanner reads values from row to ptrs, converting types from Spanner
+// to Go along the way.
+func (b *Buffer) FromSpanner(row *spanner.Row, ptrs ...interface{}) error {
+	if len(ptrs) != row.Size() {
+		panic("len(ptrs) != row.Size()")
+	}
+
+	for i, goPtr := range ptrs {
+		if err := b.fromSpanner(row, i, goPtr); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+func (b *Buffer) fromSpanner(row *spanner.Row, col int, goPtr interface{}) error {
+	b.StringSlice = b.StringSlice[:0]
+	b.ByteSlice = b.ByteSlice[:0]
+	b.Int64Slice = b.Int64Slice[:0]
+
+	var spanPtr interface{}
+	switch goPtr := goPtr.(type) {
+	case Ptr:
+		spanPtr = goPtr.SpannerPtr(b)
+	case *string:
+		spanPtr = &b.NullString
+	case **timestamppb.Timestamp:
+		spanPtr = &b.NullTime
+	case *atvpb.Status:
+		spanPtr = &b.Int64
+	case *pb.BuildStatus:
+		spanPtr = &b.Int64
+	case *[]pb.ExonerationReason:
+		spanPtr = &b.Int64Slice
+	case *pb.TestResultStatus:
+		spanPtr = &b.Int64
+	case **pb.Variant:
+		spanPtr = &b.StringSlice
+	case *[]*pb.StringPair:
+		spanPtr = &b.StringSlice
+	case proto.Message:
+		spanPtr = &b.ByteSlice
+	case *[]atvpb.Status:
+		spanPtr = &b.Int64Slice
+	case *internal.VerdictStatus:
+		spanPtr = &b.Int64
+	default:
+		spanPtr = goPtr
+	}
+
+	if err := row.Column(col, spanPtr); err != nil {
+		return errors.Annotate(err, "failed to read column %q", row.ColumnName(col)).Err()
+	}
+
+	if spanPtr == goPtr {
+		return nil
+	}
+
+	var err error
+	switch goPtr := goPtr.(type) {
+	case Ptr:
+		if err := goPtr.FromSpanner(b); err != nil {
+			return err
+		}
+
+	case *string:
+		*goPtr = ""
+		if b.NullString.Valid {
+			*goPtr = b.NullString.StringVal
+		}
+
+	case **timestamppb.Timestamp:
+		*goPtr = nil
+		if b.NullTime.Valid {
+			*goPtr = pbutil.MustTimestampProto(b.NullTime.Time)
+		}
+
+	case *atvpb.Status:
+		*goPtr = atvpb.Status(b.Int64)
+
+	case *pb.BuildStatus:
+		*goPtr = pb.BuildStatus(b.Int64)
+
+	case *[]pb.ExonerationReason:
+		*goPtr = nil
+		// Be careful to preserve NULLs.
+		if b.Int64Slice != nil {
+			*goPtr = make([]pb.ExonerationReason, len(b.Int64Slice))
+			for i, p := range b.Int64Slice {
+				(*goPtr)[i] = pb.ExonerationReason(p)
+			}
+		}
+
+	case *pb.TestResultStatus:
+		*goPtr = pb.TestResultStatus(b.Int64)
+
+	case **pb.Variant:
+		if *goPtr, err = pbutil.VariantFromStrings(b.StringSlice); err != nil {
+			// If it was written to Spanner, it should have been validated.
+			panic(err)
+		}
+
+	case *[]*pb.StringPair:
+		*goPtr = make([]*pb.StringPair, len(b.StringSlice))
+		for i, p := range b.StringSlice {
+			if (*goPtr)[i], err = pbutil.StringPairFromString(p); err != nil {
+				// If it was written to Spanner, it should have been validated.
+				panic(err)
+			}
+		}
+
+	case proto.Message:
+		if reflect.ValueOf(goPtr).IsNil() {
+			return errors.Reason("nil pointer encountered").Err()
+		}
+		if err := proto.Unmarshal(b.ByteSlice, goPtr); err != nil {
+			// If it was written to Spanner, it should have been validated.
+			panic(err)
+		}
+
+	case *internal.VerdictStatus:
+		*goPtr = internal.VerdictStatus(b.Int64)
+
+	case *[]atvpb.Status:
+		*goPtr = make([]atvpb.Status, len(b.Int64Slice))
+		for i, p := range b.Int64Slice {
+			(*goPtr)[i] = atvpb.Status(p)
+		}
+
+	default:
+		panic(fmt.Sprintf("impossible %q", goPtr))
+	}
+	return nil
+}
+
+// ToSpanner converts values from Go types to Spanner types. In addition to
+// supported types in FromSpanner, also supports []interface{} and
+// map[string]interface{}.
+func ToSpanner(v interface{}) interface{} {
+	switch v := v.(type) {
+	case Value:
+		return v.ToSpanner()
+
+	case *timestamppb.Timestamp:
+		if v == nil {
+			return spanner.NullTime{}
+		}
+		ret := spanner.NullTime{Valid: true}
+		ret.Time = v.AsTime()
+		// ptypes.Timestamp always returns a timestamp, even
+		// if the returned err is non-nil, see its documentation.
+		// The error is returned only if the timestamp violates its
+		// own invariants, which will be caught on the attempt to
+		// insert this value into Spanner.
+		// This is consistent with the behavior of spanner.Insert() and
+		// other mutating functions that ignore invalid time.Time
+		// until it is time to apply the mutation.
+		// Not returning an error here significantly simplifies usage
+		// of this function and functions based on this one.
+		return ret
+
+	case atvpb.Status:
+		return int64(v)
+
+	case pb.BuildStatus:
+		return int64(v)
+
+	case []pb.ExonerationReason:
+		var spanPtr []int64
+		// Be careful to preserve NULLs.
+		if v != nil {
+			spanPtr = make([]int64, len(v))
+			for i, s := range v {
+				spanPtr[i] = int64(s)
+			}
+		}
+		return spanPtr
+
+	case pb.TestResultStatus:
+		return int64(v)
+
+	case *pb.Variant:
+		return pbutil.VariantToStrings(v)
+
+	case []*pb.StringPair:
+		return pbutil.StringPairsToStrings(v...)
+
+	case proto.Message:
+		if reflect.ValueOf(v).IsNil() {
+			// Do not store empty messages.
+			return []byte(nil)
+		}
+
+		ret, err := proto.Marshal(v)
+		if err != nil {
+			panic(err)
+		}
+		return ret
+
+	case []interface{}:
+		ret := make([]interface{}, len(v))
+		for i, el := range v {
+			ret[i] = ToSpanner(el)
+		}
+		return ret
+
+	case map[string]interface{}:
+		ret := make(map[string]interface{}, len(v))
+		for key, el := range v {
+			ret[key] = ToSpanner(el)
+		}
+		return ret
+
+	case internal.VerdictStatus:
+		return int64(v)
+
+	case []atvpb.Status:
+		spanPtr := make([]int64, len(v))
+		for i, s := range v {
+			spanPtr[i] = int64(s)
+		}
+		return spanPtr
+
+	default:
+		return v
+	}
+}
+
+// ToSpannerMap converts a map of Go values to a map of Spanner values.
+// See also ToSpanner.
+func ToSpannerMap(values map[string]interface{}) map[string]interface{} {
+	return ToSpanner(values).(map[string]interface{})
+}
+
+// UpdateMap is a shortcut for spanner.UpdateMap with ToSpannerMap applied to
+// in.
+func UpdateMap(table string, in map[string]interface{}) *spanner.Mutation {
+	return spanner.UpdateMap(table, ToSpannerMap(in))
+}
+
+// InsertMap is a shortcut for spanner.InsertMap with ToSpannerMap applied to
+// in.
+func InsertMap(table string, in map[string]interface{}) *spanner.Mutation {
+	return spanner.InsertMap(table, ToSpannerMap(in))
+}
+
+// InsertOrUpdateMap is a shortcut for spanner.InsertOrUpdateMap with ToSpannerMap applied to
+// in.
+func InsertOrUpdateMap(table string, in map[string]interface{}) *spanner.Mutation {
+	return spanner.InsertOrUpdateMap(table, ToSpannerMap(in))
+}
diff --git a/analysis/internal/span/buffer_test.go b/analysis/internal/span/buffer_test.go
new file mode 100644
index 0000000..07279ed
--- /dev/null
+++ b/analysis/internal/span/buffer_test.go
@@ -0,0 +1,179 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package span
+
+import (
+	"reflect"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"github.com/golang/protobuf/proto"
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/pbutil"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestTypeConversion(t *testing.T) {
+	t.Parallel()
+
+	var b Buffer
+
+	test := func(goValue, spValue interface{}) {
+		// ToSpanner
+		actualSPValue := ToSpanner(goValue)
+		So(actualSPValue, ShouldResemble, spValue)
+
+		// FromSpanner
+		row, err := spanner.NewRow([]string{"a"}, []interface{}{actualSPValue})
+		So(err, ShouldBeNil)
+		goPtr := reflect.New(reflect.TypeOf(goValue))
+		err = b.FromSpanner(row, goPtr.Interface())
+		So(err, ShouldBeNil)
+		So(goPtr.Elem().Interface(), ShouldResemble, goValue)
+	}
+
+	Convey(`int64`, t, func() {
+		test(int64(42), int64(42))
+	})
+
+	Convey(`*timestamppb.Timestamp`, t, func() {
+		test(
+			&timestamppb.Timestamp{Seconds: 1000, Nanos: 1234},
+			spanner.NullTime{Valid: true, Time: time.Unix(1000, 1234).UTC()},
+		)
+	})
+
+	Convey(`atvpb.Status`, t, func() {
+		test(atvpb.Status_STATUS_UNSPECIFIED, int64(0))
+	})
+
+	Convey(`pb.BuildStatus`, t, func() {
+		test(pb.BuildStatus_BUILD_STATUS_SUCCESS, int64(1))
+	})
+	Convey(`[]pb.ExonerationReason`, t, func() {
+		test(
+			[]pb.ExonerationReason{
+				pb.ExonerationReason_OCCURS_ON_MAINLINE,
+				pb.ExonerationReason_NOT_CRITICAL,
+			},
+			[]int64{int64(1), int64(3)},
+		)
+		test(
+			[]pb.ExonerationReason{},
+			[]int64{},
+		)
+		test(
+			[]pb.ExonerationReason(nil),
+			[]int64(nil),
+		)
+	})
+	Convey(`pb.TestResultStatus`, t, func() {
+		test(pb.TestResultStatus_PASS, int64(1))
+	})
+
+	Convey(`*pb.Variant`, t, func() {
+		Convey(`Works`, func() {
+			test(
+				pbutil.Variant("a", "1", "b", "2"),
+				[]string{"a:1", "b:2"},
+			)
+		})
+		Convey(`Empty`, func() {
+			test(
+				(*pb.Variant)(nil),
+				[]string{},
+			)
+		})
+	})
+
+	Convey(`[]*pb.StringPair`, t, func() {
+		test(
+			pbutil.StringPairs("a", "1", "b", "2"),
+			[]string{"a:1", "b:2"},
+		)
+	})
+
+	Convey(`Compressed`, t, func() {
+		Convey(`Empty`, func() {
+			test(Compressed(nil), []byte(nil))
+		})
+		Convey(`non-Empty`, func() {
+			test(
+				Compressed("aaaaaaaaaaaaaaaaaaaa"),
+				[]byte{122, 116, 100, 10, 40, 181, 47, 253, 4, 0, 69, 0, 0, 8, 97, 1, 84, 1, 2, 16, 4, 247, 175, 71, 227})
+		})
+	})
+
+	Convey(`Map`, t, func() {
+		var varIntA, varIntB int64
+		var varState atvpb.Status
+
+		row, err := spanner.NewRow([]string{"a", "b", "c"}, []interface{}{int64(42), int64(56), int64(0)})
+		So(err, ShouldBeNil)
+		err = b.FromSpanner(row, &varIntA, &varIntB, &varState)
+		So(err, ShouldBeNil)
+		So(varIntA, ShouldEqual, 42)
+		So(varIntB, ShouldEqual, 56)
+		So(varState, ShouldEqual, atvpb.Status_STATUS_UNSPECIFIED)
+
+		// ToSpanner
+		spValues := ToSpannerMap(map[string]interface{}{
+			"a": varIntA,
+			"b": varIntB,
+			"c": varState,
+		})
+		So(spValues, ShouldResemble, map[string]interface{}{"a": int64(42), "b": int64(56), "c": int64(0)})
+	})
+
+	Convey(`proto.Message`, t, func() {
+		msg := &atvpb.FlakeStatistics{
+			FlakyVerdictRate: 0.5,
+		}
+		expected, err := proto.Marshal(msg)
+		So(err, ShouldBeNil)
+		So(ToSpanner(msg), ShouldResemble, expected)
+
+		row, err := spanner.NewRow([]string{"a"}, []interface{}{expected})
+		So(err, ShouldBeNil)
+
+		Convey(`success`, func() {
+			expectedPtr := &atvpb.FlakeStatistics{}
+			err = b.FromSpanner(row, expectedPtr)
+			So(err, ShouldBeNil)
+			So(expectedPtr, ShouldResembleProto, msg)
+		})
+
+		Convey(`Passing nil pointer to fromSpanner`, func() {
+			var expectedPtr *atvpb.FlakeStatistics
+			err = b.FromSpanner(row, expectedPtr)
+			So(err, ShouldErrLike, "nil pointer encountered")
+		})
+	})
+
+	Convey(`[]atvpb.Status`, t, func() {
+		test(
+			[]atvpb.Status{
+				atvpb.Status_FLAKY,
+				atvpb.Status_STATUS_UNSPECIFIED,
+			},
+			[]int64{int64(10), int64(0)},
+		)
+	})
+}
diff --git a/analysis/internal/span/compression.go b/analysis/internal/span/compression.go
new file mode 100644
index 0000000..77112bd
--- /dev/null
+++ b/analysis/internal/span/compression.go
@@ -0,0 +1,102 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package span
+
+import (
+	"bytes"
+
+	"github.com/klauspost/compress/zstd"
+
+	"go.chromium.org/luci/common/errors"
+)
+
+var zstdHeader = []byte("ztd\n")
+
+// Globally shared zstd encoder and decoder. We use only their EncodeAll and
+// DecodeAll methods which are allowed to be used concurrently. Internally, both
+// the encode and the decoder have worker pools (limited by GOMAXPROCS) and each
+// concurrent EncodeAll/DecodeAll call temporary consumes one worker (so overall
+// we do not run more jobs that we have cores for).
+var (
+	zstdEncoder *zstd.Encoder
+	zstdDecoder *zstd.Decoder
+)
+
+func init() {
+	var err error
+	if zstdEncoder, err = zstd.NewWriter(nil); err != nil {
+		panic(err) // this is impossible
+	}
+	if zstdDecoder, err = zstd.NewReader(nil); err != nil {
+		panic(err) // this is impossible
+	}
+}
+
+// Compressed instructs ToSpanner and FromSpanner functions to compress the
+// content with https://godoc.org/github.com/klauspost/compress/zstd encoding.
+type Compressed []byte
+
+// ToSpanner implements Value.
+func (c Compressed) ToSpanner() interface{} {
+	if len(c) == 0 {
+		// Do not store empty bytes.
+		return []byte(nil)
+	}
+	return Compress(c)
+}
+
+// SpannerPtr implements Ptr.
+func (c *Compressed) SpannerPtr(b *Buffer) interface{} {
+	return &b.ByteSlice
+}
+
+// FromSpanner implements Ptr.
+func (c *Compressed) FromSpanner(b *Buffer) error {
+	if len(b.ByteSlice) == 0 {
+		// do not set to nil; otherwise we lose the buffer.
+		*c = (*c)[:0]
+	} else {
+		// *c might be pointing to an existing memory buffer.
+		// Try to reuse it for decoding.
+		var err error
+		if *c, err = Decompress(b.ByteSlice, *c); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+// Compress compresses data using zstd.
+func Compress(data []byte) []byte {
+	out := make([]byte, 0, len(data)/2+len(zstdHeader)) // hope for at least 2x compression
+	out = append(out, zstdHeader...)
+	return zstdEncoder.EncodeAll(data, out)
+}
+
+// Decompress decompresses the src compressed with Compress to dest.
+// dest is the buffer for decompressed content, it will be reset to 0 length
+// before taking the content.
+func Decompress(src, dest []byte) ([]byte, error) {
+	if !bytes.HasPrefix(src, zstdHeader) {
+		return nil, errors.Reason("expected ztd header").Err()
+	}
+
+	dest, err := zstdDecoder.DecodeAll(src[len(zstdHeader):], dest[:0])
+	if err != nil {
+		return nil, errors.Annotate(err, "failed to decode from zstd").Err()
+	}
+	return dest, nil
+}
diff --git a/analysis/internal/span/doc.go b/analysis/internal/span/doc.go
new file mode 100644
index 0000000..716fe04
--- /dev/null
+++ b/analysis/internal/span/doc.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package span contains utility functions to interact with the underlying
+// Spanner storage. It does not attempt to encapsulate Spanner.
+package span
diff --git a/analysis/internal/span/init_db.sql b/analysis/internal/span/init_db.sql
new file mode 100644
index 0000000..c0e3965
--- /dev/null
+++ b/analysis/internal/span/init_db.sql
@@ -0,0 +1,719 @@
+-- Copyright 2021 The Chromium Authors. All rights reserved.
+--
+-- Use of this source code is governed by a BSD-style license that can be
+-- found in the LICENSE file.
+
+--------------------------------------------------------------------------------
+-- This script initializes a Weetbix Spanner database.
+
+-- Stores a test variant.
+-- The test variant should be:
+-- * currently flaky
+-- * suspected of flakiness that needs to be verified
+-- * flaky before but has been fixed, broken, disabled or removed
+CREATE TABLE AnalyzedTestVariants (
+  -- Security realm this test variant belongs to.
+  Realm STRING(64) NOT NULL,
+
+  -- Builder that the test variant runs on.
+  -- It must have the same value as the builder variant.
+  Builder STRING(MAX),
+
+  -- Unique identifier of the test,
+  -- see also luci.resultdb.v1.TestResult.test_id.
+  TestId STRING(MAX) NOT NULL,
+
+  -- key:value pairs to specify the way of running the test.
+  -- See also luci.resultdb.v1.TestResult.variant.
+  Variant ARRAY<STRING(MAX)>,
+
+  -- A hex-encoded sha256 of concatenated "<key>:<value>\n" variant pairs.
+  -- Combination of Realm, TestId and VariantHash can identify a test variant.
+  VariantHash STRING(64) NOT NULL,
+
+  -- Timestamp when the row of a test variant was created.
+  CreateTime TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+
+  -- Status of the analyzed test variant, see analyzedtestvariant.Status.
+  Status INT64 NOT NULL,
+  -- Timestamp when the status field was last updated.
+  StatusUpdateTime TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+  -- Timestamp the next UpdateTestVariant task is enqueued.
+  -- This timestamp is used as a token to validate an UpdateTestVariant is
+  -- expected. A task with unmatched token will be silently ignored.
+  NextUpdateTaskEnqueueTime TIMESTAMP,
+  -- Previous statuses of the analyzed test variant.
+  -- If the test variant is a newly detected one, or its status has not changed
+  -- at all, this field is empty.
+  -- With PreviousStatusUpdateTimes, they are used when exporting test variants
+  -- to BigQuery, to determine the time ranges of the rows that happened when
+  -- the test variant's status changed.
+  PreviousStatuses ARRAY<INT64>,
+  -- Previous status update times.
+  -- Must have the same number of elements as PreviousStatuses.
+  PreviousStatusUpdateTimes ARRAY<TIMESTAMP>,
+
+  -- Compressed metadata for the test case.
+  -- For example, the original test name, test location, etc.
+  -- See TestResult.test_metadata for details.
+  -- Test location is helpful for dashboards to get aggregated data by directories.
+  TestMetadata BYTES(MAX),
+
+  -- key:value pairs for the metadata of the test variant.
+  -- For example the monorail component and team email.
+  Tags ARRAY<STRING(MAX)>,
+
+  -- Flake statistics, including flake rate, failure rate and counts.
+  -- See FlakeStatistics proto.
+  FlakeStatistics BYTES(MAX),
+  -- Timestamp when the most recent flake statistics were computed.
+  FlakeStatisticUpdateTime TIMESTAMP,
+) PRIMARY KEY (Realm, TestId, VariantHash);
+
+-- Used by finding test variants with FLAKY status on a builder in
+-- CollectFlakeResults task.
+CREATE NULL_FILTERED INDEX AnalyzedTestVariantsByBuilderAndStatus
+ON AnalyzedTestVariants (Realm, Builder, Status);
+
+-- Stores results of a test variant in one invocation.
+CREATE TABLE Verdicts (
+  -- Primary Key of the parent AnalyzedTestVariants.
+  -- Security realm this test variant belongs to.
+  Realm STRING(64) NOT NULL,
+  -- Unique identifier of the test,
+  -- see also luci.resultdb.v1.TestResult.test_id.
+  TestId STRING(MAX) NOT NULL,
+  -- A hex-encoded sha256 of concatenated "<key>:<value>\n" variant pairs.
+  -- Combination of Realm, TestId and VariantHash can identify a test variant.
+  VariantHash STRING(64) NOT NULL,
+
+  -- Id of the build invocation the results belong to.
+  InvocationId STRING(MAX) NOT NULL,
+
+  -- Flag indicates if the verdict belongs to a try build.
+  IsPreSubmit BOOL,
+
+  -- Flag indicates if the try build the verdict belongs to contributes to
+  -- a CL's submission.
+  -- Verdicts with HasContributedToClSubmission as False will be filtered out
+  -- for deciding the test variant's status because they could be noises.
+  -- This field is only meaningful for PreSubmit verdicts.
+  HasContributedToClSubmission BOOL,
+
+  -- If the unexpected results in the verdict are exonerated.
+  Exonerated BOOL,
+
+  -- Status of the results for the parent test variant in this verdict,
+  -- See VerdictStatus.
+  Status INT64 NOT NULL,
+
+  -- Result counts in the verdict.
+  -- Note that SKIP results are ignored in either of the counts.
+  UnexpectedResultCount INT64,
+  TotalResultCount INT64,
+
+  --Creation time of the invocation containing this verdict.
+  InvocationCreationTime TIMESTAMP NOT NULL,
+
+  -- Ingestion time of the verdict.
+  IngestionTime TIMESTAMP NOT NULL,
+
+  -- List of colon-separated key-value pairs, where key is the cluster algorithm
+  -- and value is the cluster id.
+  -- key can be repeated.
+  -- The clusters the first test result of the verdict is in.
+  -- Once the test result reaches its retention period in the clustering
+  -- system, this will cease to be updated.
+  Clusters ARRAY<STRING(MAX)>,
+
+) PRIMARY KEY (Realm, TestId, VariantHash, InvocationId),
+INTERLEAVE IN PARENT AnalyzedTestVariants ON DELETE CASCADE;
+
+-- Used by finding most recent verdicts of a test variant to calculate status.
+CREATE NULL_FILTERED INDEX VerdictsByTestVariantAndIngestionTime
+ ON Verdicts (Realm, TestId, VariantHash, IngestionTime DESC),
+ INTERLEAVE IN AnalyzedTestVariants;
+
+-- FailureAssociationRules associate failures with bugs. When a rule
+-- is used to match incoming test failures, the resultant cluster is
+-- known as a 'bug cluster' because the failures in it are associated
+-- with a bug (via the failure association rule).
+-- The ID of a bug cluster corresponding to a rule is
+-- (Project, RuleBasedClusteringAlgorithm, RuleID), where
+-- RuleBasedClusteringAlgorithm is the algorithm name of the algorithm
+-- that clusters failures based on failure association rules (e.g.
+-- 'rules-v2'), and (Project, RuleId) is the ID of the rule.
+CREATE TABLE FailureAssociationRules (
+  -- The LUCI Project this bug belongs to.
+  Project STRING(40) NOT NULL,
+  -- The unique identifier for the rule. This is a randomly generated
+  -- 128-bit ID, encoded as 32 lowercase hexadecimal characters.
+  RuleId STRING(32) NOT NULL,
+  -- The rule predicate, defining which failures are being associated.
+  RuleDefinition STRING(4096) NOT NULL,
+  -- The time the rule was created.
+  CreationTime TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+  -- The user which created the rule. If this was auto-filed by Weetbix
+  -- itself, this is the special value 'weetbix'. Otherwise, it is
+  -- an email address.
+  -- 320 is the maximum length of an email address (64 for local part,
+  -- 1 for the '@', and 255 for the domain part).
+  CreationUser STRING(320) NOT NULL,
+  -- The last time the rule was updated.
+  LastUpdated TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+  -- The user which last updated this rule. If this was Weetbix itself,
+  -- (e.g. in case of an auto-filed bug which was created and never
+  -- modified) this is 'weetbix'. Otherwise, it is an email address.
+  LastUpdatedUser STRING(320) NOT NULL,
+  -- The time the rule was last updated in a way that caused the
+  -- matched failures to change, i.e. because of a change to RuleDefinition
+  -- or IsActive. (For comparison, updating BugID does NOT change
+  -- the matched failures, so does NOT update this field.)
+  -- When this value changes, it triggers re-clustering.
+  -- Basis for RulesVersion on ClusteringState and ReclusteringRuns.
+  PredicateLastUpdated TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+  -- The bug the failures are associated with (part 1). This is the
+  -- bug tracking system containing the bug the failures are associated
+  -- with. The only supported values are 'monorail' and 'buganizer'.
+  BugSystem STRING(16) NOT NULL,
+  -- The bug the failures are associated with (part 2). This is the
+  -- identifier of the bug the failures are associated with, as identified
+  -- by the bug tracking system itself. For monorail, the scheme is
+  -- {project}/{numeric_id}, for buganizer, the scheme is {numeric_id}.
+  BugId STRING(255) NOT NULL,
+  -- Whether the bug must still be updated by Weetbix, and whether failures
+  -- should still be matched against this rule. The only allowed
+  -- values are true or NULL (to indicate false). Only if the bug has
+  -- been closed and no failures have been observed for a while should
+  -- this be NULL. This makes it easy to retrofit a NULL_FILTERED index
+  -- in future, if it is needed for performance.
+  IsActive BOOL,
+  -- Whether this rule should manage the priority and verified status
+  -- of the associated bug based on the impact of the cluster defined
+  -- by this rule.
+  -- The only allowed values are true or NULL (to indicate false).
+  IsManagingBug BOOL,
+  -- The suggested cluster this failure association rule was created from
+  -- (if any) (part 1).
+  -- This is the algorithm component of the suggested cluster this rule
+  -- was created from.
+  -- Until re-clustering is complete (and the residual impact of the source
+  -- cluster has reduced to zero), SourceClusterAlgorithm and SourceClusterId
+  -- tell bug filing to ignore the source suggested cluster when
+  -- determining whether new bugs need to be filed.
+  SourceClusterAlgorithm STRING(32) NOT NULL,
+  -- The suggested cluster this failure association rule was created from
+  -- (if any) (part 2).
+  -- This is the algorithm-specific ID component of the suggested cluster
+  -- this rule was created from.
+  SourceClusterId STRING(32) NOT NULL,
+) PRIMARY KEY (Project, RuleId);
+
+-- The failure association rules associated with a bug. This also
+-- enforces the constraint that there is at most one rule per bug
+-- per project.
+CREATE UNIQUE INDEX FailureAssociationRuleByBugAndProject ON FailureAssociationRules(BugSystem, BugId, Project);
+
+-- Enforces the constraint that only one rule may manage a given bug
+-- at once.
+-- This is required to ensure that automatic bug filing does not attempt to
+-- take conflicting actions (i.e. simultaneously increase and decrease
+-- priority) on the same bug, because of differing priorities set by
+-- different rules.
+CREATE UNIQUE NULL_FILTERED INDEX FailureAssociationRuleByManagedBug ON FailureAssociationRules(BugSystem, BugId, IsManagingBug);
+
+-- Clustering state records the clustering state of failed test results, organised
+-- by chunk.
+CREATE TABLE ClusteringState (
+  -- The LUCI Project the test results belong to.
+  Project STRING(40) NOT NULL,
+  -- The identity of the chunk of test results. 32 lowercase hexadecimal
+  -- characters assigned by the ingestion process.
+  ChunkId STRING(32) NOT NULL,
+  -- The start of the retention period of the test results in the chunk.
+  PartitionTime TIMESTAMP NOT NULL,
+  -- The identity of the blob storing the chunk's test results.
+  ObjectId STRING(32) NOT NULL,
+  -- The version of clustering algorithms used to cluster test results in this
+  -- chunk. (This is a version over the set of algorithms, distinct from the
+  -- version of a single algorithm, e.g.:
+  -- v1 -> {reason-v1}, v2 -> {reason-v1, testname-v1},
+  -- v3 -> {reason-v2, testname-v1}.)
+  AlgorithmsVersion INT64 NOT NULL,
+  -- The version of project configuration used by algorithms to match test
+  -- results in this chunk.
+  ConfigVersion TIMESTAMP NOT NULL,
+  -- The version of the set of failure association rules used to match test
+  -- results in this chunk. This is the maximum "Predicate Last Updated" time
+  -- of any failure association rule in the snapshot of failure association
+  -- rules used to match the test results.
+  RulesVersion TIMESTAMP NOT NULL,
+  -- Serialized ChunkClusters proto containing which test result is in which
+  -- cluster.
+  Clusters BYTES(MAX) NOT NULL,
+  -- The Spanner commit timestamp of when the row was last updated.
+  LastUpdated TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+) PRIMARY KEY (Project, ChunkId)
+, ROW DELETION POLICY (OLDER_THAN(PartitionTime, INTERVAL 90 DAY));
+
+-- ReclusteringRuns contains details of runs used to re-cluster test results.
+CREATE TABLE ReclusteringRuns (
+  -- The LUCI Project.
+  Project STRING(40) NOT NULL,
+  -- The attempt. This is the timestamp the orchestrator run ends.
+  AttemptTimestamp TIMESTAMP NOT NULL,
+  -- The minimum algorithms version the reclustering run is trying to achieve.
+  -- Chunks with an AlgorithmsVersion less than this value are eligible to be
+  -- re-clustered.
+  AlgorithmsVersion INT64 NOT NULL,
+  -- The minimum config version the reclustering run is trying to achieve.
+  -- Chunks with a ConfigVersion less than this value are eligible to be
+  -- re-clustered.
+  ConfigVersion TIMESTAMP NOT NULL,
+  -- The minimum rules version the reclustering run is trying to achieve.
+  -- Chunks with a RulesVersion less than this value are eligible to be
+  -- re-clustered.
+  RulesVersion TIMESTAMP NOT NULL,
+  -- The number of shards created for this run (for this LUCI project).
+  ShardCount INT64 NOT NULL,
+  -- The number of shards that have reported progress (at least once).
+  -- When this is equal to ShardCount, readers can have confidence Progress
+  -- is a reasonable reflection of the progress made reclustering
+  -- this project. Until then, it is a loose lower-bound.
+  ShardsReported INT64 NOT NULL,
+  -- The progress. This is a value between 0 and 1000*ShardCount.
+  Progress INT64 NOT NULL,
+) PRIMARY KEY (Project, AttemptTimestamp DESC)
+-- Commented out for Cloud Spanner Emulator:
+-- https://github.com/GoogleCloudPlatform/cloud-spanner-emulator/issues/32
+-- but **should** be applied to real Spanner instances.
+--, ROW DELETION POLICY (OLDER_THAN(AttemptTimestamp, INTERVAL 90 DAY));
+
+-- Ingestions is used to synchronise and deduplicate the ingestion
+-- of test results which require data from one or more sources.
+--
+-- Ingestion may only start after two events are received:
+-- 1. The build has completed.
+-- 2. The presubmit run has completed.
+-- These events may occur in either order (e.g. 2 can occur before 1 if the
+-- presubmit run fails before all builds are complete).
+CREATE TABLE Ingestions (
+  -- The unique key for the ingestion. The current scheme is:
+  -- {buildbucket host name}/{build id}.
+  BuildId STRING(1024) NOT NULL,
+  -- The LUCI Project to which the build belongs. Populated at the same
+  -- time as the build result.
+  BuildProject STRING(40),
+  -- The build result.
+  BuildResult BYTES(MAX),
+  -- Whether the record has any build result.
+  -- Used in index to speed-up to some statistical queries.
+  HasBuildResult BOOL NOT NULL AS (BuildResult IS NOT NULL) STORED,
+  -- The Spanner commit time the build result was populated.
+  BuildJoinedTime TIMESTAMP OPTIONS (allow_commit_timestamp=true),
+  -- Is the build part of a presubmit run? If yes, then ingestion should
+  -- wait for the presubmit result to be populated before commencing ingestion.
+  -- Use 'true' to indicate true and NULL to indicate false.
+  IsPresubmit BOOL,
+  -- The LUCI Project to which the presubmit run belongs. Populated at the
+  -- same time as the presubmit run result.
+  PresubmitProject STRING(40),
+  -- The presubmit result.
+  PresubmitResult BYTES(MAX),
+  -- Whether the record has any presubmit result.
+  -- Used in index to speed-up to some statistical queries.
+  HasPresubmitResult BOOL NOT NULL AS (PresubmitResult IS NOT NULL) STORED,
+  -- The Spanner commit time the presubmit result was populated.
+  PresubmitJoinedTime TIMESTAMP OPTIONS (allow_commit_timestamp=true),
+  -- The Spanner commit time the row last last updated.
+  LastUpdated TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+  -- The number of test result ingestion tasks have been created for this
+  -- invocation.
+  -- Used to avoid duplicate scheduling of ingestion tasks. If the page_index
+  -- is the index of the page being processed, an ingestion task for the next
+  -- page will only be created if (page_index + 1) == TaskCount.
+  TaskCount INT64,
+) PRIMARY KEY (BuildId)
+-- 90 days retention, plus some margin (10 days) to ensure ingestion records
+-- are always retained longer than the ingested results (acknowledging
+-- the partition time on ingested chunks may be later than the LastUpdated
+-- time if clocks are not synchronised).
+--
+-- Commented out for Cloud Spanner Emulator:
+-- https://github.com/GoogleCloudPlatform/cloud-spanner-emulator/issues/32
+-- but **should** be applied to real Spanner instances.
+--, ROW DELETION POLICY (OLDER_THAN(LastUpdated, INTERVAL 100 DAY));
+
+-- Used to speed-up querying join statistics for presubmit runs.
+CREATE NULL_FILTERED INDEX IngestionsByIsPresubmit
+  ON Ingestions(IsPresubmit, BuildId)
+  STORING (BuildProject,     HasBuildResult,     BuildJoinedTime,
+           PresubmitProject, HasPresubmitResult, PresubmitJoinedTime);
+
+-- Stores transactional tasks reminders.
+-- See https://go.chromium.org/luci/server/tq. Scanned by tq-sweeper-spanner.
+CREATE TABLE TQReminders (
+    ID STRING(MAX) NOT NULL,
+    FreshUntil TIMESTAMP NOT NULL,
+    Payload BYTES(102400) NOT NULL,
+) PRIMARY KEY (ID ASC);
+
+CREATE TABLE TQLeases (
+    SectionID STRING(MAX) NOT NULL,
+    LeaseID INT64 NOT NULL,
+    SerializedParts ARRAY<STRING(MAX)>,
+    ExpiresAt TIMESTAMP NOT NULL,
+) PRIMARY KEY (SectionID ASC, LeaseID ASC);
+
+-- Stores test results.
+-- As of Q2 2022, this table is estimated to collect ~250 billion rows over
+-- 90 days. Please be mindful of storage implications when adding new fields.
+-- https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#storage_size_for_data_types
+-- gives guidance on the storage sizes of data types.
+CREATE TABLE TestResults (
+  -- The LUCI Project this test result belongs to.
+  Project STRING(40) NOT NULL,
+
+  -- Unique identifier of the test.
+  -- This has the same value as luci.resultdb.v1.TestResult.test_id.
+  TestId STRING(MAX) NOT NULL,
+
+  -- Partition time, as determined by Weetbix ingestion. Start time of the
+  -- ingested build (for postsubmit results) or start time of the presubmit run
+  -- (for presubmit results). Defines date/time axis of test results plotted
+  -- by date/time.
+  -- Including as part of Primary Key allows direct filtering of data for test
+  -- to last N days. This could be used to improve performance for tests with
+  -- many results, or allow experimentation with keeping longer histories
+  -- (e.g. 120 days) without incurring performance penalty on time-windowed
+  -- queries.
+  PartitionTime TIMESTAMP NOT NULL,
+
+  -- A hex-encoded sha256 of concatenated "<key>:<value>\n" variant pairs.
+  -- Computed as hex(sha256(<concatenated_key_value_pairs>)[:8]),
+  -- where concatenated_key_value_pairs is the result of concatenating
+  -- variant pairs formatted as "<key>:<value>\n" in ascending key order.
+  -- Combination of Realm, TestId and VariantHash can identify a test variant.
+  VariantHash STRING(16) NOT NULL,
+
+  -- The invocation from which these test results were ingested.
+  -- This is the top-level invocation that was ingested.
+  IngestedInvocationId STRING(MAX) NOT NULL,
+
+  -- The index of the test run that contained this test result.
+  -- The test run of a test result is the invocation it is directly
+  -- included inside; typically the invocation for the swarming task
+  -- the tests ran as part of.
+  -- Indexes are assigned to runs based on the order they appear to have
+  -- run in, starting from zero (based on test result timestamps).
+  -- However, if two test runs overlap, the order of indexes for those test
+  -- runs is not guaranteed.
+  RunIndex INT64 NOT NULL,
+
+  -- The index of the test result in the run. The first test result that
+  -- was produced in a run will have index 0, the second will have index 1,
+  -- and so on.
+  ResultIndex INT64 NOT NULL,
+
+  -- Whether the test result was expected.
+  -- The value 'true' is used to encode true, and NULL encodes false.
+  IsUnexpected BOOL,
+
+  -- How long the test execution took, in microseconds.
+  RunDurationUsec INT64,
+
+  -- The test result status.
+  Status INT64 NOT NULL,
+
+  -- The reasons (if any) the test verdict was exonerated.
+  -- If this array is null, the test verdict was not exonerated.
+  -- (Non-null) empty array values are not used.
+  -- This field is stored denormalised. It is guaranteed to be the same for
+  -- all results for a test variant in an ingested invocation.
+  ExonerationReasons ARRAY<INT64>,
+
+  -- The following data is stored denormalised. It is guaranteed to be
+  -- the same for all results for the ingested invocation.
+
+  -- The realm of the test result, excluding project. 62 as ResultDB allows
+  -- at most 64 characters for the construction "<project>:<realm>" and project
+  -- must be at least one character.
+  SubRealm STRING(62) NOT NULL,
+
+  -- The status of the build that contained this test result. Can be used
+  -- to filter incomplete results (e.g. where build was cancelled or had
+  -- an infra failure).
+  -- See weetbix.v1.BuildStatus.
+  BuildStatus INT64 NOT NULL,
+
+  -- The owner of the presubmit run.
+  -- This owner of the CL on which CQ+1/CQ+2 was clicked
+  -- (even in case of presubmit run with multiple CLs).
+  -- There is scope for this field to become an email address if privacy
+  -- approval is obtained, until then it is "automation" (for automation
+  -- service accounts) and "user" otherwise.
+  -- Only populated for builds part of presubmit runs.
+  PresubmitRunOwner STRING(320),
+
+  -- The run mode of the presubmit run (e.g. DRY RUN, FULL RUN).
+  -- Only populated for builds part of presubmit runs.
+  PresubmitRunMode INT64,
+
+  -- The identity of the git reference defining the code line that was tested.
+  -- This excludes any unsubmitted changes that were tested, which are
+  -- noted separately in the Changelist... fields below.
+  --
+  -- The details of the git reference is stored in the GitReferences table,
+  -- keyed by (Project, GitReferenceHash).
+  --
+  -- Only populated if CommitPosition is populated.
+  GitReferenceHash BYTES(8),
+
+  -- The commit position along the given git reference that was tested.
+  -- This excludes any unsubmitted changes that were tested, which are
+  -- noted separately in the Changelist... fields below.
+  -- This is populated from the buildbucket build outputs or inputs, usually
+  -- as calculated via goto.google.com/git-numberer.
+  --
+  -- Only populated if build reports the commit position as part of the
+  -- build outputs or inputs.
+  CommitPosition INT64,
+
+  -- The following fields capture information about any unsubmitted
+  -- changelists that were tested by the test execution. The arrays
+  -- are matched in length and correspond in index, i.e.
+  -- ChangelistHosts[OFFSET(0)] corresponds with ChangelistChanges[OFFSET(0)]
+  -- and ChangelistPatchsets[OFFSET(0)].
+  -- Changelists are stored in ascending lexicographical order (over
+  -- (hostname, change, patchset)).
+  -- They will be set for all presubmit runs, and may be set for other
+  -- builds as well (even those outside a formal LUCI CV run) based on
+  -- buildbucket inputs. At most 10 changelists are included.
+
+  -- Hostname(s) of the gerrit instance of the changelist that was tested
+  -- (if any). For storage efficiency, the suffix "-review.googlesource.com"
+  -- is not stored. Only gerrit hosts are supported.
+  -- 56 chars because maximum length of a domain name label is 63 chars,
+  -- and we subtract 7 chars for "-review".
+  ChangelistHosts ARRAY<STRING(56)> NOT NULL,
+
+  -- The changelist number(s), e.g. 12345.
+  ChangelistChanges ARRAY<INT64> NOT NULL,
+
+  -- The patchset number(s) of the changelist, e.g. 1.
+  ChangelistPatchsets ARRAY<INT64> NOT NULL,
+) PRIMARY KEY(Project, TestId, PartitionTime DESC, VariantHash, IngestedInvocationId, RunIndex, ResultIndex)
+, ROW DELETION POLICY (OLDER_THAN(PartitionTime, INTERVAL 90 DAY));
+
+-- Stores git references. Git references represent a linear source code
+-- history along which the position of commits can be measured
+-- using an integer (where larger integer means later in history and
+-- smaller integer means earlier in history).
+CREATE TABLE GitReferences (
+  -- The LUCI Project this git reference was used in.
+  -- Although the same git reference could be used in different projects,
+  -- it is stored namespaced by project to isolate projects from each other.
+  Project STRING(40) NOT NULL,
+
+  -- The identity of the git reference.
+  -- Constructed by hashing the following values:
+  -- - The gittiles hostname, e.g. "chromium.googlesource.com".
+  -- - The repository name, e.g. "chromium/src".
+  -- - The reference name, e.g. "refs/heads/main".
+  -- Using the following formula ([:8] indicates truncation to 8 bytes).
+  -- SHA256(hostname + "\n" + repository_name + "\n"  + ref_name)[:8].
+  GitReferenceHash BYTES(8) NOT NULL,
+
+  -- The gittiles hostname. E.g. "chromium.googlesource.com".
+  -- 255 characters for max length of a domain name.
+  Hostname STRING(255) NOT NULL,
+
+  -- The gittiles repository name (also known as the gittiles "project").
+  -- E.g. "chromium/src".
+  -- 4096 for the maximum length of a linux path.
+  Repository STRING(4096) NOT NULL,
+
+  -- The git reference name, e.g. "refs/heads/main".
+  Reference STRING(4096) NOT NULL,
+
+  -- Last (ingestion) time this git reference was observed.
+  -- This value may be out of date by up to 24 hours to allow for contention-
+  -- reducing strategies.
+  LastIngestionTime TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+) PRIMARY KEY(Project, GitReferenceHash)
+-- Use a slightly longer retention period to prevent the git reference being
+-- dropped before the associated TestResults.
+, ROW DELETION POLICY (OLDER_THAN(LastIngestionTime, INTERVAL 100 DAY));
+
+-- Stores top-level invocations which were ingested.
+--
+-- TODO(crbug.com/1266759):
+-- This forms part of an experiment embedded into the design.
+-- If joining to this table is efficient, we may leave Changelist,
+-- Build Status, realm and commit position data here and drop it
+-- off the TestResults table.
+-- If not, we may decide to delete this table.
+CREATE TABLE IngestedInvocations (
+  -- The LUCI Project the invocation is a part of.
+  Project STRING(40) NOT NULL,
+
+  -- The (top-level) invocation which was ingested.
+  IngestedInvocationId STRING(MAX) NOT NULL,
+
+  -- The realm of the invocation, excluding project. 62 as ResultDB allows
+  -- at most 64 characters for the construction "<project>:<realm>" and project
+  -- must be at least one character.
+  SubRealm STRING(62) NOT NULL,
+
+  -- Partition time, as determined by Weetbix ingestion. Start time of the
+  -- ingested build (for postsubmit results) or start time of the presubmit run
+  -- (for presubmit results).
+  PartitionTime TIMESTAMP NOT NULL,
+
+  -- The status of the build that contained this test result. Can be used
+  -- to filter incomplete results (e.g. where build was cancelled or had
+  -- an infra failure).
+  -- See weetbix.v1.BuildStatus.
+  BuildStatus INT64,
+
+  -- The owner of the presubmit run.
+  -- This owner of the CL on which CQ+1/CQ+2 was clicked
+  -- (even in case of presubmit run with multiple CLs).
+  -- There is scope for this field to become an email address if privacy
+  -- approval is obtained, until then it is "automation" (for automation
+  -- service accounts) and "user" otherwise.
+  -- Only populated for builds part of presubmit runs.
+  PresubmitRunOwner STRING(320),
+
+  -- The run mode of the presubmit run (e.g. DRY RUN, FULL RUN).
+  -- Only populated for builds part of presubmit runs.
+  PresubmitRunMode INT64,
+
+
+  -- The identity of the git reference defining the code line that was tested.
+  -- This excludes any unsubmitted changes that were tested, which are
+  -- noted separately in the Changelist... fields below.
+  --
+  -- The details of the git reference is stored in the GitReferences table,
+  -- keyed by (Project, GitReferenceHash).
+  --
+  -- Only populated if CommitPosition is populated.
+  GitReferenceHash BYTES(8),
+
+  -- The commit position along the given git reference that was tested.
+  -- This excludes any unsubmitted changes that were tested, which are
+  -- noted separately in the Changelist... fields below.
+  -- This is populated from the buildbucket build outputs or inputs, usually
+  -- as calculated via goto.google.com/git-numberer.
+  --
+  -- Only populated if build reports the commit position as part of the
+  -- build outputs or inputs.
+  CommitPosition INT64,
+
+  -- The SHA-1 commit hash of the commit that was tested.
+  -- Encoded as a lowercase hexadecimal string.
+  -- This excludes any unsubmitted changes that were tested, which are
+  -- noted separately in the Changelist... fields below.
+  --
+  -- Only populated if CommitPosition is populated.
+  CommitHash STRING(40),
+
+  -- The following fields capture information about any unsubmitted
+  -- changelists that were tested by the test execution. The arrays
+  -- are matched in length and correspond in index, i.e.
+  -- ChangelistHosts[OFFSET(0)] corresponds with ChangelistChanges[OFFSET(0)]
+  -- and ChangelistPatchsets[OFFSET(0)].
+  -- Changelists are stored in ascending lexicographical order (over
+  -- (hostname, change, patchset)).
+  -- They will be set for all presubmit runs, and may be set for other
+  -- builds as well (even those outside a formal LUCI CV run) based on
+  -- buildbucket inputs. At most 10 changelists are included.
+
+  -- Hostname(s) of the gerrit instance of the changelist that was tested
+  -- (if any). For storage efficiency, the suffix "-review.googlesource.com"
+  -- is not stored. Only gerrit hosts are supported.
+  -- 56 chars because maximum length of a domain name label is 63 chars,
+  -- and we subtract 7 chars for "-review".
+  ChangelistHosts ARRAY<STRING(56)> NOT NULL,
+
+  -- The changelist number(s), e.g. 12345.
+  ChangelistChanges ARRAY<INT64> NOT NULL,
+
+  -- The patchset number(s) of the changelist, e.g. 1.
+  ChangelistPatchsets ARRAY<INT64> NOT NULL,
+) PRIMARY KEY(Project, IngestedInvocationId)
+-- Use a slightly longer retention period to prevent the invocation being
+-- dropped before the associated TestResults.
+, ROW DELETION POLICY (OLDER_THAN(PartitionTime, INTERVAL 100 DAY));
+
+-- Serves two purposes:
+-- - Permits listing of distinct variants observed for a test in a project,
+--   filtered by Realm.
+--
+-- - Provides a mapping back from VariantHash to variant.
+--
+-- TODO(crbug.com/1266759):
+-- UniqueTestVariants table in ResultDB will be superseded by this table and
+-- will need to be deleted.
+CREATE TABLE TestVariantRealms (
+  -- The LUCI Project in which the variant was observed.
+  Project STRING(40) NOT NULL,
+
+  -- Unique identifier of the test from which the variant was observed,
+  -- This has the same value as luci.resultdb.v1.TestResult.test_id.
+  TestId STRING(MAX) NOT NULL,
+
+  -- A hex-encoded sha256 of concatenated "<key>:<value>\n" variant pairs.
+  -- Computed as hex(sha256(<concatenated_key_value_pairs>)[:8]),
+  -- where concatenated_key_value_pairs is the result of concatenating
+  -- variant pairs formatted as "<key>:<value>\n" in ascending key order.
+  -- Combination of Realm, TestId and VariantHash can identify a test variant.
+  VariantHash STRING(16) NOT NULL,
+
+  -- The realm of the test result from which the variant was observed, excluding
+  -- project. 62 as ResultDB allows at most 64 characters for the construction
+  -- "<project>:<realm>" and project must be at least one character.
+  SubRealm STRING(62) NOT NULL,
+
+  -- key:value pairs to specify the way of running the test.
+  -- See also luci.resultdb.v1.TestResult.variant.
+  Variant ARRAY<STRING(MAX)>,
+
+  -- Other information about the test variant, like information from tags,
+  -- could be captured here, as is currently the case for AnalyzedTestVariants.
+  -- (e.g. test ownership).
+
+  -- Last (ingestion) time this test variant was observed in the realm.
+  -- This value may be out of date by up to 24 hours to allow for contention-
+  -- reducing strategies.
+  LastIngestionTime TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+) PRIMARY KEY(Project, TestId, VariantHash, SubRealm)
+-- Use a slightly longer retention period to prevent the invocation being
+-- dropped before the associated TestResults.
+, ROW DELETION POLICY (OLDER_THAN(LastIngestionTime, INTERVAL 100 DAY));
+
+-- Permits listing of distinct tests observed for a project, filtered by Realm.
+-- This table is created to support test ID substring search, which can often
+-- lead to a full table scan, which will be significantly slower in the
+-- TestVariantRealms table.
+CREATE TABLE TestRealms (
+  -- The LUCI Project in which the variant was observed.
+  Project STRING(40) NOT NULL,
+
+  -- Unique identifier of the test from which the variant was observed,
+  -- This has the same value as luci.resultdb.v1.TestResult.test_id.
+  TestId STRING(MAX) NOT NULL,
+
+  -- The realm of the test result from which the variant was observed, excluding
+  -- project. 62 as ResultDB allows at most 64 characters for the construction
+  -- "<project>:<realm>" and project must be at least one character.
+  SubRealm STRING(62) NOT NULL,
+
+  -- Last (ingestion) time this test variant was observed in the realm.
+  -- This value may be out of date by up to 24 hours to allow for contention-
+  -- reducing strategies.
+  LastIngestionTime TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
+) PRIMARY KEY(Project, TestId, SubRealm)
+-- Use a slightly longer retention period to prevent the invocation being
+-- dropped before the associated TestResults.
+, ROW DELETION POLICY (OLDER_THAN(LastIngestionTime, INTERVAL 100 DAY));
diff --git a/analysis/internal/span/tagging.go b/analysis/internal/span/tagging.go
new file mode 100644
index 0000000..32bca12
--- /dev/null
+++ b/analysis/internal/span/tagging.go
@@ -0,0 +1,37 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package span
+
+import (
+	"context"
+
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/grpc"
+)
+
+// SpannerDefaultsInterceptor returns a gRPC interceptor that adds default
+// Spanner request options to the context.
+//
+// The request tag will be set the to RPC method name.
+//
+// See also ModifyRequestOptions in luci/server/span.
+func SpannerDefaultsInterceptor() grpc.UnaryServerInterceptor {
+	return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
+		ctx = span.ModifyRequestOptions(ctx, func(opts *span.RequestOptions) {
+			opts.Tag = info.FullMethod
+		})
+		return handler(ctx, req)
+	}
+}
diff --git a/analysis/internal/span/util.go b/analysis/internal/span/util.go
new file mode 100644
index 0000000..0f8fc67
--- /dev/null
+++ b/analysis/internal/span/util.go
@@ -0,0 +1,44 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package span
+
+import (
+	"bytes"
+	"strings"
+	"text/template"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+)
+
+// GenerateStatement generates a spanner statement from a text template.
+func GenerateStatement(tmpl *template.Template, name string, input interface{}) (spanner.Statement, error) {
+	sql := &bytes.Buffer{}
+	err := tmpl.ExecuteTemplate(sql, name, input)
+	if err != nil {
+		return spanner.Statement{}, errors.Annotate(err, "failed to generate statement: %s", name).Err()
+	}
+	return spanner.NewStatement(sql.String()), nil
+}
+
+// QuoteLike turns a literal string into an escaped like expression.
+// This means strings like test_name will only match as expected, rather than
+// also matching test3name.
+func QuoteLike(value string) string {
+	value = strings.ReplaceAll(value, "\\", "\\\\")
+	value = strings.ReplaceAll(value, "%", "\\%")
+	value = strings.ReplaceAll(value, "_", "\\_")
+	return value
+}
diff --git a/analysis/internal/tasks/taskspb/gen.go b/analysis/internal/tasks/taskspb/gen.go
new file mode 100644
index 0000000..5027729
--- /dev/null
+++ b/analysis/internal/tasks/taskspb/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package taskspb
+
+//go:generate cproto
diff --git a/analysis/internal/tasks/taskspb/tasks.pb.go b/analysis/internal/tasks/taskspb/tasks.pb.go
new file mode 100644
index 0000000..0a1176c
--- /dev/null
+++ b/analysis/internal/tasks/taskspb/tasks.pb.go
@@ -0,0 +1,966 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.27.1
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/internal/tasks/taskspb/tasks.proto
+
+package taskspb
+
+import (
+	v1 "go.chromium.org/luci/resultdb/proto/v1"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	proto "go.chromium.org/luci/analysis/internal/ingestion/control/proto"
+	analyzedtestvariant "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	v11 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Payload of IngestTestResults task.
+type IngestTestResults struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Timestamp representing the start of the data retention period
+	// for the ingested test results. In case of multiple builds
+	// ingested for one CV run, the partition_time used for all
+	// builds must be the same.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// The build that is being ingested.
+	Build *proto.BuildResult `protobuf:"bytes,8,opt,name=build,proto3" json:"build,omitempty"`
+	// Context about the presubmit run the build was a part of. Only
+	// populated if the build is a presubmit run.
+	PresubmitRun *proto.PresubmitResult `protobuf:"bytes,9,opt,name=presubmit_run,json=presubmitRun,proto3" json:"presubmit_run,omitempty"`
+	// The page token value to use when calling QueryTestVariants.
+	// For the first task, this should be "". For subsequent tasks,
+	// this is the next_page_token value returned by the last call.
+	PageToken string `protobuf:"bytes,10,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+	// The task number of test results task. 0 for the first
+	// task, 1 for the second task, and so on. Used to avoid creating
+	// duplicate tasks.
+	TaskIndex int64 `protobuf:"varint,11,opt,name=task_index,json=taskIndex,proto3" json:"task_index,omitempty"`
+}
+
+func (x *IngestTestResults) Reset() {
+	*x = IngestTestResults{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *IngestTestResults) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*IngestTestResults) ProtoMessage() {}
+
+func (x *IngestTestResults) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use IngestTestResults.ProtoReflect.Descriptor instead.
+func (*IngestTestResults) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *IngestTestResults) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *IngestTestResults) GetBuild() *proto.BuildResult {
+	if x != nil {
+		return x.Build
+	}
+	return nil
+}
+
+func (x *IngestTestResults) GetPresubmitRun() *proto.PresubmitResult {
+	if x != nil {
+		return x.PresubmitRun
+	}
+	return nil
+}
+
+func (x *IngestTestResults) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+func (x *IngestTestResults) GetTaskIndex() int64 {
+	if x != nil {
+		return x.TaskIndex
+	}
+	return 0
+}
+
+// ResultDB-specific information.
+type ResultDB struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Information of the invocation.
+	Invocation *v1.Invocation `protobuf:"bytes,1,opt,name=invocation,proto3" json:"invocation,omitempty"`
+	// Hostname of the ResultDB instance, such as "results.api.cr.dev".
+	Host string `protobuf:"bytes,2,opt,name=host,proto3" json:"host,omitempty"`
+}
+
+func (x *ResultDB) Reset() {
+	*x = ResultDB{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ResultDB) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ResultDB) ProtoMessage() {}
+
+func (x *ResultDB) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ResultDB.ProtoReflect.Descriptor instead.
+func (*ResultDB) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ResultDB) GetInvocation() *v1.Invocation {
+	if x != nil {
+		return x.Invocation
+	}
+	return nil
+}
+
+func (x *ResultDB) GetHost() string {
+	if x != nil {
+		return x.Host
+	}
+	return ""
+}
+
+// Payload of CollectTestResults task.
+type CollectTestResults struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// ResultDB-specific information.
+	Resultdb *ResultDB `protobuf:"bytes,1,opt,name=resultdb,proto3" json:"resultdb,omitempty"`
+	// Builder of the invocation.
+	Builder string `protobuf:"bytes,2,opt,name=builder,proto3" json:"builder,omitempty"`
+	// If the task is for a try build.
+	IsPreSubmit bool `protobuf:"varint,3,opt,name=is_pre_submit,json=isPreSubmit,proto3" json:"is_pre_submit,omitempty"`
+	// If the try build contributes to a CL's submission.
+	ContributedToClSubmission bool `protobuf:"varint,4,opt,name=contributed_to_cl_submission,json=contributedToClSubmission,proto3" json:"contributed_to_cl_submission,omitempty"`
+}
+
+func (x *CollectTestResults) Reset() {
+	*x = CollectTestResults{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CollectTestResults) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CollectTestResults) ProtoMessage() {}
+
+func (x *CollectTestResults) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CollectTestResults.ProtoReflect.Descriptor instead.
+func (*CollectTestResults) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *CollectTestResults) GetResultdb() *ResultDB {
+	if x != nil {
+		return x.Resultdb
+	}
+	return nil
+}
+
+func (x *CollectTestResults) GetBuilder() string {
+	if x != nil {
+		return x.Builder
+	}
+	return ""
+}
+
+func (x *CollectTestResults) GetIsPreSubmit() bool {
+	if x != nil {
+		return x.IsPreSubmit
+	}
+	return false
+}
+
+func (x *CollectTestResults) GetContributedToClSubmission() bool {
+	if x != nil {
+		return x.ContributedToClSubmission
+	}
+	return false
+}
+
+// Information that can form a key to an AnalyzedTestVariant row.
+type TestVariantKey struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Realm       string `protobuf:"bytes,1,opt,name=realm,proto3" json:"realm,omitempty"`
+	TestId      string `protobuf:"bytes,2,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	VariantHash string `protobuf:"bytes,3,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+}
+
+func (x *TestVariantKey) Reset() {
+	*x = TestVariantKey{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantKey) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantKey) ProtoMessage() {}
+
+func (x *TestVariantKey) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantKey.ProtoReflect.Descriptor instead.
+func (*TestVariantKey) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *TestVariantKey) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *TestVariantKey) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *TestVariantKey) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+// Payload of UpdateTestVariant task.
+type UpdateTestVariant struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	TestVariantKey *TestVariantKey `protobuf:"bytes,1,opt,name=test_variant_key,json=testVariantKey,proto3" json:"test_variant_key,omitempty"`
+	// The time this task is ready to be enqueued.
+	// The task will run only if this time matches the AnalyzedTestVariants row's
+	// NextUpdateTaskEnqueueTime.
+	EnqueueTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=enqueue_time,json=enqueueTime,proto3" json:"enqueue_time,omitempty"`
+}
+
+func (x *UpdateTestVariant) Reset() {
+	*x = UpdateTestVariant{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UpdateTestVariant) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateTestVariant) ProtoMessage() {}
+
+func (x *UpdateTestVariant) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateTestVariant.ProtoReflect.Descriptor instead.
+func (*UpdateTestVariant) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *UpdateTestVariant) GetTestVariantKey() *TestVariantKey {
+	if x != nil {
+		return x.TestVariantKey
+	}
+	return nil
+}
+
+func (x *UpdateTestVariant) GetEnqueueTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.EnqueueTime
+	}
+	return nil
+}
+
+// Payload of ExportTestVariants task.
+type ExportTestVariants struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// LUCI Realm. Test variants in this realm are exported.
+	Realm string `protobuf:"bytes,1,opt,name=realm,proto3" json:"realm,omitempty"`
+	// BigQuery table to export test variants to.
+	CloudProject string `protobuf:"bytes,2,opt,name=cloud_project,json=cloudProject,proto3" json:"cloud_project,omitempty"`
+	Dataset      string `protobuf:"bytes,3,opt,name=dataset,proto3" json:"dataset,omitempty"`
+	Table        string `protobuf:"bytes,4,opt,name=table,proto3" json:"table,omitempty"`
+	// Represents a function Variant -> bool.
+	// Test variants satisfy this predicate are exported.
+	Predicate *analyzedtestvariant.Predicate `protobuf:"bytes,5,opt,name=predicate,proto3" json:"predicate,omitempty"`
+	// Time range of the task.
+	// The ranges serves 2 purposes:
+	// - Test variants satisfy the predicate within the time_range are exported.
+	// - Each row uses this time_range as their default time range*. Meaning each row
+	//   contains the information of the test variants within the time range,
+	//   especially, the row contains the verdicts that weetbix ingested within
+	//   the range, and compute the flake_statistics using those verdicts.
+	//   * Note that a row can have a narrower time_range, if the test variant's
+	//     status changes within the time_range.
+	TimeRange *v11.TimeRange `protobuf:"bytes,6,opt,name=time_range,json=timeRange,proto3" json:"time_range,omitempty"`
+}
+
+func (x *ExportTestVariants) Reset() {
+	*x = ExportTestVariants{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ExportTestVariants) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExportTestVariants) ProtoMessage() {}
+
+func (x *ExportTestVariants) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ExportTestVariants.ProtoReflect.Descriptor instead.
+func (*ExportTestVariants) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ExportTestVariants) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *ExportTestVariants) GetCloudProject() string {
+	if x != nil {
+		return x.CloudProject
+	}
+	return ""
+}
+
+func (x *ExportTestVariants) GetDataset() string {
+	if x != nil {
+		return x.Dataset
+	}
+	return ""
+}
+
+func (x *ExportTestVariants) GetTable() string {
+	if x != nil {
+		return x.Table
+	}
+	return ""
+}
+
+func (x *ExportTestVariants) GetPredicate() *analyzedtestvariant.Predicate {
+	if x != nil {
+		return x.Predicate
+	}
+	return nil
+}
+
+func (x *ExportTestVariants) GetTimeRange() *v11.TimeRange {
+	if x != nil {
+		return x.TimeRange
+	}
+	return nil
+}
+
+// Payload of the ReclusterChunks task.
+type ReclusterChunks struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The LUCI Project containing test results to be re-clustered.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// The attempt time for which this task is. This should be cross-referenced
+	// with the ReclusteringRuns table to identify the reclustering parameters.
+	// This is also the soft deadline for the task.
+	AttemptTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=attempt_time,json=attemptTime,proto3" json:"attempt_time,omitempty"`
+	// The exclusive lower bound defining the range of Chunk IDs to
+	// be re-clustered. To define the table start, use the empty string ("").
+	StartChunkId string `protobuf:"bytes,3,opt,name=start_chunk_id,json=startChunkId,proto3" json:"start_chunk_id,omitempty"`
+	// The inclusive upper bound defining the range of Chunk IDs to
+	// be re-clustered. To define the table end use "ff" x 16, i.e.
+	// "ffffffffffffffffffffffffffffffff".
+	EndChunkId string `protobuf:"bytes,4,opt,name=end_chunk_id,json=endChunkId,proto3" json:"end_chunk_id,omitempty"`
+	// State to be passed from one execution of the task to the next.
+	// To fit with autoscaling, each task aims to execute only for a short time
+	// before enqueuing another task to act as its continuation.
+	// Must be populated on all tasks, even on the initial task.
+	State *ReclusterChunkState `protobuf:"bytes,5,opt,name=state,proto3" json:"state,omitempty"`
+}
+
+func (x *ReclusterChunks) Reset() {
+	*x = ReclusterChunks{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ReclusterChunks) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ReclusterChunks) ProtoMessage() {}
+
+func (x *ReclusterChunks) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ReclusterChunks.ProtoReflect.Descriptor instead.
+func (*ReclusterChunks) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ReclusterChunks) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *ReclusterChunks) GetAttemptTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.AttemptTime
+	}
+	return nil
+}
+
+func (x *ReclusterChunks) GetStartChunkId() string {
+	if x != nil {
+		return x.StartChunkId
+	}
+	return ""
+}
+
+func (x *ReclusterChunks) GetEndChunkId() string {
+	if x != nil {
+		return x.EndChunkId
+	}
+	return ""
+}
+
+func (x *ReclusterChunks) GetState() *ReclusterChunkState {
+	if x != nil {
+		return x.State
+	}
+	return nil
+}
+
+// ReclusterChunkState captures state passed from one execution of a
+// ReclusterChunks task to the next.
+type ReclusterChunkState struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The exclusive lower bound of Chunk IDs processed to date.
+	CurrentChunkId string `protobuf:"bytes,1,opt,name=current_chunk_id,json=currentChunkId,proto3" json:"current_chunk_id,omitempty"`
+	// The next time a progress report should be made.
+	NextReportDue *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=next_report_due,json=nextReportDue,proto3" json:"next_report_due,omitempty"`
+	// Whether progress has been reported at least once.
+	ReportedOnce bool `protobuf:"varint,3,opt,name=reported_once,json=reportedOnce,proto3" json:"reported_once,omitempty"`
+	// The last progress value which was reported.
+	LastReportedProgress int64 `protobuf:"varint,4,opt,name=last_reported_progress,json=lastReportedProgress,proto3" json:"last_reported_progress,omitempty"`
+}
+
+func (x *ReclusterChunkState) Reset() {
+	*x = ReclusterChunkState{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ReclusterChunkState) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ReclusterChunkState) ProtoMessage() {}
+
+func (x *ReclusterChunkState) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ReclusterChunkState.ProtoReflect.Descriptor instead.
+func (*ReclusterChunkState) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *ReclusterChunkState) GetCurrentChunkId() string {
+	if x != nil {
+		return x.CurrentChunkId
+	}
+	return ""
+}
+
+func (x *ReclusterChunkState) GetNextReportDue() *timestamppb.Timestamp {
+	if x != nil {
+		return x.NextReportDue
+	}
+	return nil
+}
+
+func (x *ReclusterChunkState) GetReportedOnce() bool {
+	if x != nil {
+		return x.ReportedOnce
+	}
+	return false
+}
+
+func (x *ReclusterChunkState) GetLastReportedProgress() int64 {
+	if x != nil {
+		return x.LastReportedProgress
+	}
+	return 0
+}
+
+var File_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDesc = []byte{
+	0x0a, 0x3a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x70, 0x62,
+	0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x16, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x74,
+	0x61, 0x73, 0x6b, 0x73, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x37, 0x67, 0x6f, 0x2e, 0x63, 0x68, 0x72, 0x6f, 0x6d, 0x69,
+	0x75, 0x6d, 0x2e, 0x6f, 0x72, 0x67, 0x2f, 0x6c, 0x75, 0x63, 0x69, 0x2f, 0x72, 0x65, 0x73, 0x75,
+	0x6c, 0x74, 0x64, 0x62, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x69, 0x6e,
+	0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31,
+	0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x41, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x6e, 0x61,
+	0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x2f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x1a, 0x46, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2f, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x63, 0x6f, 0x6e,
+	0x74, 0x72, 0x6f, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x74, 0x72,
+	0x6f, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc7, 0x02, 0x0a, 0x11, 0x49, 0x6e, 0x67,
+	0x65, 0x73, 0x74, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x41,
+	0x0a, 0x0e, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x52, 0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d,
+	0x65, 0x12, 0x45, 0x0a, 0x05, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x2f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72,
+	0x6e, 0x61, 0x6c, 0x2e, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x63, 0x6f,
+	0x6e, 0x74, 0x72, 0x6f, 0x6c, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c,
+	0x74, 0x52, 0x05, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x12, 0x58, 0x0a, 0x0d, 0x70, 0x72, 0x65, 0x73,
+	0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x33, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e,
+	0x61, 0x6c, 0x2e, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x63, 0x6f, 0x6e,
+	0x74, 0x72, 0x6f, 0x6c, 0x2e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x65,
+	0x73, 0x75, 0x6c, 0x74, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52,
+	0x75, 0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
+	0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65,
+	0x6e, 0x12, 0x1d, 0x0a, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18,
+	0x0b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x09, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x6e, 0x64, 0x65, 0x78,
+	0x4a, 0x04, 0x08, 0x01, 0x10, 0x02, 0x4a, 0x04, 0x08, 0x02, 0x10, 0x03, 0x4a, 0x04, 0x08, 0x04,
+	0x10, 0x08, 0x22, 0x5c, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x44, 0x42, 0x12, 0x3c,
+	0x0a, 0x0a, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6c, 0x75, 0x63, 0x69, 0x2e, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74,
+	0x64, 0x62, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x52, 0x0a, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04,
+	0x68, 0x6f, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74,
+	0x22, 0xd1, 0x01, 0x0a, 0x12, 0x43, 0x6f, 0x6c, 0x6c, 0x65, 0x63, 0x74, 0x54, 0x65, 0x73, 0x74,
+	0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x12, 0x3c, 0x0a, 0x08, 0x72, 0x65, 0x73, 0x75, 0x6c,
+	0x74, 0x64, 0x62, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x74, 0x61, 0x73,
+	0x6b, 0x73, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x44, 0x42, 0x52, 0x08, 0x72, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x64, 0x62, 0x12, 0x18, 0x0a, 0x07, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x65, 0x72, 0x12,
+	0x22, 0x0a, 0x0d, 0x69, 0x73, 0x5f, 0x70, 0x72, 0x65, 0x5f, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x69, 0x73, 0x50, 0x72, 0x65, 0x53, 0x75, 0x62,
+	0x6d, 0x69, 0x74, 0x12, 0x3f, 0x0a, 0x1c, 0x63, 0x6f, 0x6e, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74,
+	0x65, 0x64, 0x5f, 0x74, 0x6f, 0x5f, 0x63, 0x6c, 0x5f, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73,
+	0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x19, 0x63, 0x6f, 0x6e, 0x74, 0x72,
+	0x69, 0x62, 0x75, 0x74, 0x65, 0x64, 0x54, 0x6f, 0x43, 0x6c, 0x53, 0x75, 0x62, 0x6d, 0x69, 0x73,
+	0x73, 0x69, 0x6f, 0x6e, 0x22, 0x62, 0x0a, 0x0e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x4b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x17, 0x0a, 0x07,
+	0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74,
+	0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x76, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x22, 0xa4, 0x01, 0x0a, 0x11, 0x55, 0x70, 0x64,
+	0x61, 0x74, 0x65, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x12, 0x50,
+	0x0a, 0x10, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x6b,
+	0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x74, 0x61, 0x73, 0x6b,
+	0x73, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x4b, 0x65, 0x79,
+	0x52, 0x0e, 0x74, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x4b, 0x65, 0x79,
+	0x12, 0x3d, 0x0a, 0x0c, 0x65, 0x6e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x52, 0x0b, 0x65, 0x6e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x22,
+	0xfb, 0x01, 0x0a, 0x12, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x23, 0x0a, 0x0d,
+	0x63, 0x6c, 0x6f, 0x75, 0x64, 0x5f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x12, 0x18, 0x0a, 0x07, 0x64, 0x61, 0x74, 0x61, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x64, 0x61, 0x74, 0x61, 0x73, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74,
+	0x61, 0x62, 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x61, 0x62, 0x6c,
+	0x65, 0x12, 0x44, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x18, 0x05,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x61,
+	0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61,
+	0x6e, 0x74, 0x2e, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x09, 0x70, 0x72,
+	0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x34, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x5f,
+	0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e,
+	0x67, 0x65, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x22, 0xf5, 0x01,
+	0x0a, 0x0f, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x43, 0x68, 0x75, 0x6e, 0x6b,
+	0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x3d, 0x0a, 0x0c, 0x61,
+	0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x61,
+	0x74, 0x74, 0x65, 0x6d, 0x70, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x24, 0x0a, 0x0e, 0x73, 0x74,
+	0x61, 0x72, 0x74, 0x5f, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0c, 0x73, 0x74, 0x61, 0x72, 0x74, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x64,
+	0x12, 0x20, 0x0a, 0x0c, 0x65, 0x6e, 0x64, 0x5f, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x64,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x6e, 0x64, 0x43, 0x68, 0x75, 0x6e, 0x6b,
+	0x49, 0x64, 0x12, 0x41, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x2b, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x69, 0x6e, 0x74, 0x65,
+	0x72, 0x6e, 0x61, 0x6c, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x52, 0x65, 0x63, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x65, 0x52, 0x05,
+	0x73, 0x74, 0x61, 0x74, 0x65, 0x22, 0xde, 0x01, 0x0a, 0x13, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x28, 0x0a,
+	0x10, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x75, 0x72, 0x72, 0x65, 0x6e, 0x74,
+	0x43, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x64, 0x12, 0x42, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f,
+	0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x5f, 0x64, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x6e, 0x65,
+	0x78, 0x74, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x44, 0x75, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x72,
+	0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x6f, 0x6e, 0x63, 0x65, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x08, 0x52, 0x0c, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x4f, 0x6e, 0x63, 0x65,
+	0x12, 0x34, 0x0a, 0x16, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65,
+	0x64, 0x5f, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03,
+	0x52, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, 0x50, 0x72,
+	0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x42, 0x30, 0x5a, 0x2e, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f,
+	0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73,
+	0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescData = file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_goTypes = []interface{}{
+	(*IngestTestResults)(nil),             // 0: weetbix.internal.tasks.IngestTestResults
+	(*ResultDB)(nil),                      // 1: weetbix.internal.tasks.ResultDB
+	(*CollectTestResults)(nil),            // 2: weetbix.internal.tasks.CollectTestResults
+	(*TestVariantKey)(nil),                // 3: weetbix.internal.tasks.TestVariantKey
+	(*UpdateTestVariant)(nil),             // 4: weetbix.internal.tasks.UpdateTestVariant
+	(*ExportTestVariants)(nil),            // 5: weetbix.internal.tasks.ExportTestVariants
+	(*ReclusterChunks)(nil),               // 6: weetbix.internal.tasks.ReclusterChunks
+	(*ReclusterChunkState)(nil),           // 7: weetbix.internal.tasks.ReclusterChunkState
+	(*timestamppb.Timestamp)(nil),         // 8: google.protobuf.Timestamp
+	(*proto.BuildResult)(nil),             // 9: weetbix.internal.ingestion.control.BuildResult
+	(*proto.PresubmitResult)(nil),         // 10: weetbix.internal.ingestion.control.PresubmitResult
+	(*v1.Invocation)(nil),                 // 11: luci.resultdb.v1.Invocation
+	(*analyzedtestvariant.Predicate)(nil), // 12: weetbix.analyzedtestvariant.Predicate
+	(*v11.TimeRange)(nil),                 // 13: weetbix.v1.TimeRange
+}
+var file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_depIdxs = []int32{
+	8,  // 0: weetbix.internal.tasks.IngestTestResults.partition_time:type_name -> google.protobuf.Timestamp
+	9,  // 1: weetbix.internal.tasks.IngestTestResults.build:type_name -> weetbix.internal.ingestion.control.BuildResult
+	10, // 2: weetbix.internal.tasks.IngestTestResults.presubmit_run:type_name -> weetbix.internal.ingestion.control.PresubmitResult
+	11, // 3: weetbix.internal.tasks.ResultDB.invocation:type_name -> luci.resultdb.v1.Invocation
+	1,  // 4: weetbix.internal.tasks.CollectTestResults.resultdb:type_name -> weetbix.internal.tasks.ResultDB
+	3,  // 5: weetbix.internal.tasks.UpdateTestVariant.test_variant_key:type_name -> weetbix.internal.tasks.TestVariantKey
+	8,  // 6: weetbix.internal.tasks.UpdateTestVariant.enqueue_time:type_name -> google.protobuf.Timestamp
+	12, // 7: weetbix.internal.tasks.ExportTestVariants.predicate:type_name -> weetbix.analyzedtestvariant.Predicate
+	13, // 8: weetbix.internal.tasks.ExportTestVariants.time_range:type_name -> weetbix.v1.TimeRange
+	8,  // 9: weetbix.internal.tasks.ReclusterChunks.attempt_time:type_name -> google.protobuf.Timestamp
+	7,  // 10: weetbix.internal.tasks.ReclusterChunks.state:type_name -> weetbix.internal.tasks.ReclusterChunkState
+	8,  // 11: weetbix.internal.tasks.ReclusterChunkState.next_report_due:type_name -> google.protobuf.Timestamp
+	12, // [12:12] is the sub-list for method output_type
+	12, // [12:12] is the sub-list for method input_type
+	12, // [12:12] is the sub-list for extension type_name
+	12, // [12:12] is the sub-list for extension extendee
+	0,  // [0:12] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_init() }
+func file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_init() {
+	if File_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*IngestTestResults); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ResultDB); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CollectTestResults); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantKey); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UpdateTestVariant); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ExportTestVariants); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ReclusterChunks); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ReclusterChunkState); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   8,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto = out.File
+	file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_rawDesc = nil
+	file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_goTypes = nil
+	file_infra_appengine_weetbix_internal_tasks_taskspb_tasks_proto_depIdxs = nil
+}
diff --git a/analysis/internal/tasks/taskspb/tasks.proto b/analysis/internal/tasks/taskspb/tasks.proto
new file mode 100644
index 0000000..16dada9
--- /dev/null
+++ b/analysis/internal/tasks/taskspb/tasks.proto
@@ -0,0 +1,159 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.internal.tasks;
+
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/resultdb/proto/v1/invocation.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+import "go.chromium.org/luci/analysis/proto/analyzedtestvariant/predicate.proto";
+import "go.chromium.org/luci/analysis/internal/ingestion/control/proto/control.proto";
+
+option go_package = "go.chromium.org/luci/analysis/internal/tasks/taskspb";
+
+// Payload of IngestTestResults task.
+message IngestTestResults {
+  reserved 1, 2, 4 to 7;
+
+  // Timestamp representing the start of the data retention period
+  // for the ingested test results. In case of multiple builds
+  // ingested for one CV run, the partition_time used for all
+  // builds must be the same.
+  google.protobuf.Timestamp partition_time = 3;
+
+  // The build that is being ingested.
+  weetbix.internal.ingestion.control.BuildResult build = 8;
+
+  // Context about the presubmit run the build was a part of. Only
+  // populated if the build is a presubmit run.
+  weetbix.internal.ingestion.control.PresubmitResult presubmit_run = 9;
+
+  // The page token value to use when calling QueryTestVariants.
+  // For the first task, this should be "". For subsequent tasks,
+  // this is the next_page_token value returned by the last call.
+  string page_token = 10;
+
+  // The task number of test results task. 0 for the first
+  // task, 1 for the second task, and so on. Used to avoid creating
+  // duplicate tasks.
+  int64 task_index = 11;
+}
+
+// ResultDB-specific information.
+message ResultDB {
+  // Information of the invocation.
+  luci.resultdb.v1.Invocation invocation = 1;
+  // Hostname of the ResultDB instance, such as "results.api.cr.dev".
+  string host = 2;
+}
+
+// Payload of CollectTestResults task.
+message CollectTestResults {
+  // ResultDB-specific information.
+  ResultDB resultdb = 1;
+  // Builder of the invocation.
+  string builder = 2;
+  // If the task is for a try build.
+  bool is_pre_submit = 3;
+  // If the try build contributes to a CL's submission.
+  bool contributed_to_cl_submission = 4;
+}
+
+// Information that can form a key to an AnalyzedTestVariant row.
+message TestVariantKey {
+  string realm = 1;
+  string test_id = 2;
+  string variant_hash = 3;
+
+}
+// Payload of UpdateTestVariant task.
+message UpdateTestVariant {
+  TestVariantKey test_variant_key = 1;
+
+  // The time this task is ready to be enqueued.
+  // The task will run only if this time matches the AnalyzedTestVariants row's
+  // NextUpdateTaskEnqueueTime.
+  google.protobuf.Timestamp enqueue_time = 2;
+}
+
+
+// Payload of ExportTestVariants task.
+message ExportTestVariants {
+  // LUCI Realm. Test variants in this realm are exported.
+  string realm = 1;
+
+  // BigQuery table to export test variants to.
+  string cloud_project = 2;
+  string dataset = 3;
+  string table = 4;
+
+  // Represents a function Variant -> bool.
+  // Test variants satisfy this predicate are exported.
+  weetbix.analyzedtestvariant.Predicate predicate = 5;
+
+  // Time range of the task.
+  // The ranges serves 2 purposes:
+  // - Test variants satisfy the predicate within the time_range are exported.
+  // - Each row uses this time_range as their default time range*. Meaning each row
+  //   contains the information of the test variants within the time range,
+  //   especially, the row contains the verdicts that weetbix ingested within
+  //   the range, and compute the flake_statistics using those verdicts.
+  //   * Note that a row can have a narrower time_range, if the test variant's
+  //     status changes within the time_range.
+  weetbix.v1.TimeRange time_range = 6;
+}
+
+// Payload of the ReclusterChunks task.
+message ReclusterChunks {
+  // The LUCI Project containing test results to be re-clustered.
+  string project = 1;
+
+  // The attempt time for which this task is. This should be cross-referenced
+  // with the ReclusteringRuns table to identify the reclustering parameters.
+  // This is also the soft deadline for the task.
+  google.protobuf.Timestamp attempt_time = 2;
+
+  // The exclusive lower bound defining the range of Chunk IDs to
+  // be re-clustered. To define the table start, use the empty string ("").
+  string start_chunk_id = 3;
+
+  // The inclusive upper bound defining the range of Chunk IDs to
+  // be re-clustered. To define the table end use "ff" x 16, i.e.
+  // "ffffffffffffffffffffffffffffffff".
+  string end_chunk_id = 4;
+
+  // State to be passed from one execution of the task to the next.
+  // To fit with autoscaling, each task aims to execute only for a short time
+  // before enqueuing another task to act as its continuation.
+  // Must be populated on all tasks, even on the initial task.
+  ReclusterChunkState state = 5;
+}
+
+// ReclusterChunkState captures state passed from one execution of a
+// ReclusterChunks task to the next.
+message ReclusterChunkState {
+  // The exclusive lower bound of Chunk IDs processed to date.
+  string current_chunk_id = 1;
+
+  // The next time a progress report should be made.
+  google.protobuf.Timestamp next_report_due = 2;
+
+  // Whether progress has been reported at least once.
+  bool reported_once = 3;
+
+  // The last progress value which was reported.
+  int64 last_reported_progress = 4;
+}
diff --git a/analysis/internal/testresults/gitreferences/main_test.go b/analysis/internal/testresults/gitreferences/main_test.go
new file mode 100644
index 0000000..f122783
--- /dev/null
+++ b/analysis/internal/testresults/gitreferences/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gitreferences
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/testresults/gitreferences/span.go b/analysis/internal/testresults/gitreferences/span.go
new file mode 100644
index 0000000..fb3f744
--- /dev/null
+++ b/analysis/internal/testresults/gitreferences/span.go
@@ -0,0 +1,167 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package gitreferences contains methods for creating and reading
+// git references in Spanner.
+package gitreferences
+
+import (
+	"bytes"
+	"context"
+	"crypto/sha256"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/grpc/codes"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+)
+
+// GitReference represents a row in the GitReferences table.
+type GitReference struct {
+	// Project is the name of the LUCI Project.
+	Project string
+	// GitReferenceHash is a unique key for the git reference.
+	// Computed using GitReferenceHash(...).
+	GitReferenceHash []byte
+	// Hostname is the gittiles hostname. E.g. "chromium.googlesource.com".
+	Hostname string
+	// The gittiles repository name (also known as the gittiles "project").
+	// E.g. "chromium/src".
+	Repository string
+	// The git reference name. E.g. "refs/heads/main".
+	Reference string
+	// Last (ingestion) time this git reference was observed.
+	// This value may be out of date by up to 24 hours to allow for contention-
+	// reducing strategies.
+	LastIngestionTime time.Time
+}
+
+func GitReferenceHash(hostname, repository, reference string) []byte {
+	result := sha256.Sum256([]byte(hostname + "\n" + repository + "\n" + reference))
+	return result[:8]
+}
+
+// EnsureExists ensures the given GitReference exists in the database.
+// It must be called in a Spanner transactional context.
+func EnsureExists(ctx context.Context, r *GitReference) error {
+	if err := validateGitReference(r); err != nil {
+		return err
+	}
+
+	key := spanner.Key{r.Project, r.GitReferenceHash}
+	row, err := span.ReadRow(ctx, "GitReferences", key, []string{"Hostname", "Repository", "Reference"})
+	if err != nil {
+		if spanner.ErrCode(err) != codes.NotFound {
+			return errors.Annotate(err, "reading GitReference").Err()
+		}
+		// Row not found. Create it.
+		row := map[string]interface{}{
+			"Project":           r.Project,
+			"GitReferenceHash":  r.GitReferenceHash,
+			"Hostname":          r.Hostname,
+			"Repository":        r.Repository,
+			"Reference":         r.Reference,
+			"LastIngestionTime": spanner.CommitTimestamp,
+		}
+		span.BufferWrite(ctx, spanner.InsertMap("GitReferences", spanutil.ToSpannerMap(row)))
+	} else {
+		var hostname, repository, reference string
+		if err := row.Columns(&hostname, &repository, &reference); err != nil {
+			return errors.Annotate(err, "reading GitReference columns").Err()
+		}
+		// At time of design, there are only ~500 unique GitReferences in
+		// chromium in the last 90 days, so a collision in a 2^64
+		// keyspace is exceedingly remote and not expected to occur
+		// by chance in the life of the design. (Only if an attacker
+		// gained system access and deliberately engineered a collision).
+		// As a collision could allow a git reference only used in a
+		// private realm to overwrite one visible from the public realm
+		// (or vice-versa), to safeguard data privacy, verify no collision
+		// occurred.
+		if r.Hostname != hostname || r.Repository != repository || r.Reference != reference {
+			return errors.Reason("GitReferenceHash collision between (%s:%s:%s) and (%s:%s:%s)",
+				r.Hostname, r.Repository, r.Reference, hostname, repository, reference).Err()
+		}
+
+		// Entry exists. Perform a blind write of the LastIngestionTime.
+		// This should not cause contention as we did not read
+		// the LastIngestionTime cell in the same transaction.
+		// See https://cloud.google.com/spanner/docs/transactions#locking.
+		row := map[string]interface{}{
+			"Project":           r.Project,
+			"GitReferenceHash":  r.GitReferenceHash,
+			"LastIngestionTime": spanner.CommitTimestamp,
+		}
+		span.BufferWrite(ctx, spanner.UpdateMap("GitReferences", row))
+	}
+	return nil
+}
+
+// validateGitReference validates that the GitReference is valid.
+func validateGitReference(cr *GitReference) error {
+	if !config.ProjectRe.MatchString(cr.Project) {
+		return errors.Reason("Project does not match pattern %s", config.ProjectRePattern).Err()
+	}
+	if cr.Hostname == "" || len(cr.Hostname) > 255 {
+		return errors.Reason("Hostname must have a length between 1 and 255").Err()
+	}
+	if cr.Repository == "" || len(cr.Repository) > 4096 {
+		return errors.Reason("Repository must have a length between 1 and 4096").Err()
+	}
+	if cr.Reference == "" || len(cr.Reference) > 4096 {
+		return errors.Reason("Reference must have a length between 1 and 4096").Err()
+	}
+	gitRefHash := GitReferenceHash(cr.Hostname, cr.Repository, cr.Reference)
+	if !bytes.Equal(gitRefHash, cr.GitReferenceHash) {
+		return errors.Reason("GitReferenceHash is unset or inconsistent, expected %v", gitRefHash).Err()
+	}
+	return nil
+}
+
+// ReadAll reads all git references. Provided for testing only.
+func ReadAll(ctx context.Context) ([]*GitReference, error) {
+	cols := []string{"Project", "GitReferenceHash", "Hostname", "Repository", "Reference", "LastIngestionTime"}
+	it := span.Read(ctx, "GitReferences", spanner.AllKeys(), cols)
+
+	var results []*GitReference
+	err := it.Do(func(r *spanner.Row) error {
+		var project string
+		var gitRefHash []byte
+		var hostname, repository, reference string
+		var lastIngestionTime time.Time
+		err := r.Columns(&project, &gitRefHash, &hostname, &repository, &reference, &lastIngestionTime)
+		if err != nil {
+			return err
+		}
+
+		ref := &GitReference{
+			Project:           project,
+			GitReferenceHash:  gitRefHash,
+			Hostname:          hostname,
+			Repository:        repository,
+			Reference:         reference,
+			LastIngestionTime: lastIngestionTime,
+		}
+		results = append(results, ref)
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return results, nil
+}
diff --git a/analysis/internal/testresults/gitreferences/span_test.go b/analysis/internal/testresults/gitreferences/span_test.go
new file mode 100644
index 0000000..734e47b
--- /dev/null
+++ b/analysis/internal/testresults/gitreferences/span_test.go
@@ -0,0 +1,154 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package gitreferences
+
+import (
+	"context"
+	"strings"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestSpan(t *testing.T) {
+	Convey("With Spanner Test Database", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		Convey("EnsureExists", func() {
+			save := func(r *GitReference) (time.Time, error) {
+				commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					return EnsureExists(ctx, r)
+				})
+				return commitTime.In(time.UTC), err
+			}
+			entry := &GitReference{
+				Project: "testproject",
+				GitReferenceHash: GitReferenceHash(
+					"mysource.googlesource.com", "chromium/src", "refs/heads/main"),
+				Hostname:   "mysource.googlesource.com",
+				Repository: "chromium/src",
+				Reference:  "refs/heads/main",
+			}
+
+			Convey("First EnsureExists creates entry", func() {
+				commitTime, err := save(entry)
+				So(err, ShouldBeNil)
+
+				expectedEntry := &GitReference{}
+				*expectedEntry = *entry
+				expectedEntry.GitReferenceHash = []byte{76, 190, 164, 46, 95, 208, 176, 7}
+				expectedEntry.LastIngestionTime = commitTime
+
+				refs, err := ReadAll(span.Single(ctx))
+				So(err, ShouldBeNil)
+				So(refs, ShouldResemble, []*GitReference{expectedEntry})
+
+				Convey("Repeated EnsureExists updates LastIngestionTime", func() {
+					// Save again.
+					commitTime, err = save(entry)
+					So(err, ShouldBeNil)
+
+					expectedEntry.LastIngestionTime = commitTime
+					refs, err = ReadAll(span.Single(ctx))
+					So(err, ShouldBeNil)
+					So(refs, ShouldResemble, []*GitReference{expectedEntry})
+				})
+			})
+			Convey("Hash collisions are detected", func() {
+				// Hash collisions are not expected to occur in the lifetime
+				// of the design, but are detected to avoid data consistency
+				// issues arising. Such data consistency issues could
+				// lead to a public realm seeing GitReference data for a private
+				// realm (if the hash of public GitReference collies
+				// with a private GitReference). In this case, we would prefer
+				// to prevent write of the colliding GitReference and not ingest
+				// the test results rather than overwrite the existing
+				// GitReference.
+				_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+					// Insert a fake colliding entry. The hash of this entry
+					// does not actually match its contents, but we pretend
+					// it does.
+					row := map[string]interface{}{
+						"Project":           "testproject",
+						"GitReferenceHash":  entry.GitReferenceHash,
+						"Hostname":          "othersource.googlesource.com",
+						"Repository":        "otherrepo/src",
+						"Reference":         "refs/heads/other",
+						"LastIngestionTime": spanner.CommitTimestamp,
+					}
+					span.BufferWrite(ctx, spanner.InsertMap("GitReferences", row))
+					return nil
+				})
+				So(err, ShouldBeNil)
+
+				_, err = save(entry)
+				So(err, ShouldErrLike, "GitReferenceHash collision")
+			})
+			Convey("Invalid entries are rejected", func() {
+				Convey("Project is empty", func() {
+					entry.Project = ""
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Project does not match pattern")
+				})
+				Convey("Project is invalid", func() {
+					entry.Project = "!invalid"
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Project does not match pattern")
+				})
+				Convey("GitReferenceHash is invalid", func() {
+					entry.GitReferenceHash = nil
+					_, err := save(entry)
+					So(err, ShouldErrLike, "GitReferenceHash is unset or inconsistent")
+				})
+				Convey("Hostname is empty", func() {
+					entry.Hostname = ""
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Hostname must have a length between 1 and 255")
+				})
+				Convey("Hostname is too long", func() {
+					entry.Hostname = strings.Repeat("h", 256)
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Hostname must have a length between 1 and 255")
+				})
+				Convey("Repository is empty", func() {
+					entry.Repository = ""
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Repository must have a length between 1 and 4096")
+				})
+				Convey("Repository is too long", func() {
+					entry.Repository = strings.Repeat("r", 4097)
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Repository must have a length between 1 and 4096")
+				})
+				Convey("Reference is empty", func() {
+					entry.Reference = ""
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Reference must have a length between 1 and 4096")
+				})
+				Convey("Reference is too long", func() {
+					entry.Reference = strings.Repeat("f", 4097)
+					_, err := save(entry)
+					So(err, ShouldErrLike, "Reference must have a length between 1 and 4096")
+				})
+			})
+		})
+	})
+}
diff --git a/analysis/internal/testresults/main_test.go b/analysis/internal/testresults/main_test.go
new file mode 100644
index 0000000..0089a0a
--- /dev/null
+++ b/analysis/internal/testresults/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/testresults/query_failure_rate.go b/analysis/internal/testresults/query_failure_rate.go
new file mode 100644
index 0000000..13de567
--- /dev/null
+++ b/analysis/internal/testresults/query_failure_rate.go
@@ -0,0 +1,557 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"context"
+	"text/template"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/sync/parallel"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const (
+	// The maximum number of workers to run in parallel.
+	// Given 100 is the maximum number of test variants queried at once,
+	// it is desirable that maxWorkers * batchSize >= 100.
+	maxWorkers = 10
+
+	// The size of each batch (in test variants).
+	batchSize = 10
+)
+
+// QueryFailureRateOptions specifies options for QueryFailureRate().
+type QueryFailureRateOptions struct {
+	Project      string
+	TestVariants []*pb.TestVariantIdentifier
+	AsAtTime     time.Time
+}
+
+// QueryFailureRate queries the failure rate of nominated test variants.
+//
+// Must be called in a Spanner transactional context. Context must
+// support multiple reads (i.e. NOT spanner.Single()) as request may
+// batched over multiple reads.
+func QueryFailureRate(ctx context.Context, opts QueryFailureRateOptions) (*pb.QueryTestVariantFailureRateResponse, error) {
+	batches := partitionIntoBatches(opts.TestVariants)
+
+	intervals := defineIntervals(opts.AsAtTime)
+
+	err := parallel.WorkPool(maxWorkers, func(c chan<- func() error) {
+		for _, b := range batches {
+			// Assign batch to a local variable to ensure its current
+			// value is captured by function closures.
+			batch := b
+			c <- func() error {
+				var err error
+				// queryFailureRateShard ensures test variants appear
+				// in the output in the same order as they appear in the
+				// input.
+				batch.output, err = queryFailureRateShard(ctx, opts.Project, batch.input, intervals)
+				return err
+			}
+		}
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	// The order of test variants in the output should be the
+	// same as the input. Perform the inverse to what we did
+	// in batching.
+	analysis := make([]*pb.TestVariantFailureRateAnalysis, 0, len(opts.TestVariants))
+	for _, b := range batches {
+		analysis = append(analysis, b.output...)
+	}
+
+	response := &pb.QueryTestVariantFailureRateResponse{
+		Intervals:    toPBIntervals(intervals),
+		TestVariants: analysis,
+	}
+	return response, nil
+}
+
+type batch struct {
+	input  []*pb.TestVariantIdentifier
+	output []*pb.TestVariantFailureRateAnalysis
+}
+
+// partitionIntoBatches partitions a list of test variants into batches.
+func partitionIntoBatches(tvs []*pb.TestVariantIdentifier) []*batch {
+	var batches []*batch
+	batchInput := make([]*pb.TestVariantIdentifier, 0, batchSize)
+	for _, tv := range tvs {
+		if len(batchInput) >= batchSize {
+			batches = append(batches, &batch{
+				input: batchInput,
+			})
+			batchInput = make([]*pb.TestVariantIdentifier, 0, batchSize)
+		}
+		batchInput = append(batchInput, tv)
+	}
+	if len(batchInput) > 0 {
+		batches = append(batches, &batch{
+			input: batchInput,
+		})
+	}
+	return batches
+}
+
+// queryFailureRateShard reads failure rate statistics for test variants.
+// Must be called in a spanner transactional context.
+func queryFailureRateShard(ctx context.Context, project string, testVariants []*pb.TestVariantIdentifier, intervals []interval) ([]*pb.TestVariantFailureRateAnalysis, error) {
+	type testVariant struct {
+		TestID      string
+		VariantHash string
+	}
+
+	tvs := make([]testVariant, 0, len(testVariants))
+	for _, ptv := range testVariants {
+		variantHash := ptv.VariantHash
+		if variantHash == "" {
+			variantHash = pbutil.VariantHash(ptv.Variant)
+		}
+
+		tvs = append(tvs, testVariant{
+			TestID:      ptv.TestId,
+			VariantHash: variantHash,
+		})
+	}
+
+	stmt, err := spanutil.GenerateStatement(failureRateQueryTmpl, failureRateQueryTmpl.Name(), nil)
+	if err != nil {
+		return nil, err
+	}
+	stmt.Params = map[string]interface{}{
+		"project":             project,
+		"testVariants":        tvs,
+		"afterPartitionTime":  queryStartTime(intervals),
+		"beforePartitionTime": queryEndTime(intervals),
+		"skip":                int64(pb.TestResultStatus_SKIP),
+	}
+
+	results := make([]*pb.TestVariantFailureRateAnalysis, 0, len(tvs))
+
+	index := 0
+	var b spanutil.Buffer
+	err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error {
+		var testID, variantHash string
+		var intervalStats []*intervalStats
+		var runFlakyExamples []*verdictExample
+		var recentVerdicts []*recentVerdict
+
+		err := b.FromSpanner(
+			row,
+			&testID,
+			&variantHash,
+			&intervalStats,
+			&runFlakyExamples,
+			&recentVerdicts,
+		)
+		if err != nil {
+			return err
+		}
+
+		analysis := &pb.TestVariantFailureRateAnalysis{}
+		if testID != tvs[index].TestID || variantHash != tvs[index].VariantHash {
+			// This should never happen, as the SQL statement is designed
+			// to return results in the same order as test variants requested.
+			panic("results in incorrect order")
+		}
+
+		analysis.TestId = testID
+		analysis.Variant = testVariants[index].Variant
+		analysis.VariantHash = testVariants[index].VariantHash
+		analysis.IntervalStats = toPBIntervalStats(intervalStats, intervals)
+		analysis.RunFlakyVerdictExamples = toPBVerdictExamples(runFlakyExamples)
+		analysis.RecentVerdicts = toPBRecentVerdicts(recentVerdicts)
+		results = append(results, analysis)
+		index++
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	return results, nil
+}
+
+// jumpBack24WeekdayHours calculates the start time of an interval
+// ending at the given endTime, such that the interval includes exactly
+// 24 hours of weekday data (in UTC). Where there are multiple interval
+// start times satisfying this criteria, the latest time is selected.
+//
+// For example, if the endTime is 08:21 on Tuesday, we would pick 08:21
+// on Monday as the start time, as the period from start time to end time
+// includes 24 hours of weekday (split over Monday and Tuesday).
+//
+// If the endTime is 08:21 on Monday, we would pick 08:21 on the previous
+// Friday as the start time, as the period from start time to end time
+// includes 24 hours of weekday (split over the Friday and Monday).
+//
+// If the endTime is midnight on the morning of Tuesday, any start time from
+// midnight on Saturday morning to midnight on Monday morning would produce
+// an interval that includes 24 hours of weekday data (i.e. the 24 hours of
+// Monday). In this case, we pick midnight on Monday. This is the only case
+// that is ambiguous.
+//
+// Rationale:
+// Many projects see reduced testing activity on weekends, as fewer CLs are
+// submitted. To avoid a dip in the sample size of statistics returned
+// on these days (which stops exoneration), we effectively bunch weekend
+// data together with Friday data in one period.
+func jumpBack24WeekdayHours(endTime time.Time) time.Time {
+	endTime = endTime.In(time.UTC)
+	var startTime time.Time
+	switch endTime.Weekday() {
+	case time.Saturday:
+		// Take us back to Saturday at 0:00.
+		startTime = endTime.Truncate(24 * time.Hour)
+		// Now take us back to Friday at 0:00.
+		startTime = startTime.Add(-1 * 24 * time.Hour)
+	case time.Sunday:
+		// Take us back to Sunday at 0:00.
+		startTime = endTime.Truncate(24 * time.Hour)
+		// Now take us back to Friday at 0:00.
+		startTime = startTime.Add(-2 * 24 * time.Hour)
+	case time.Monday:
+		// Take take us back to the same time
+		// on the Friday.
+		startTime = endTime.Add(-3 * 24 * time.Hour)
+	default:
+		// Take take us back to the same time
+		// on the previous day (which will be a weekday).
+		startTime = endTime.Add(-24 * time.Hour)
+	}
+	return startTime
+}
+
+// interval represents a time interval of data to be returned by
+// QueryFailureRate.
+type interval struct {
+	// The interval start time (inclusive).
+	startTime time.Time
+	// The interval end time (exclusive).
+	endTime time.Time
+}
+
+// defineIntervals defines the time intervals that should be returned
+// by the QueryFailureRate query. This comprises five consecutive
+// 24 weekday hour intervals ending at the given asAtTime.
+//
+// The first interval is the most recent interval, which ends at asAtTime
+// and starts at such a time that means the interval includes 24
+// hours of weekday data (as measured in UTC).
+// The second interval ends at the start time of the first interval,
+// and is generated in a similar fashion, and so on for the other
+// three intervals.
+// See jumpBack24WeekdayHours for how "24 weekday hours" is defined.
+func defineIntervals(asAtTime time.Time) []interval {
+	const intervalCount = 5
+	result := make([]interval, 0, intervalCount)
+	endTime := asAtTime.In(time.UTC)
+	for i := 0; i < intervalCount; i++ {
+		startTime := jumpBack24WeekdayHours(endTime)
+		result = append(result, interval{
+			startTime: startTime,
+			endTime:   endTime,
+		})
+		endTime = startTime
+	}
+	return result
+}
+
+// queryStartTime returns the start of the partition time range that
+// should be queried (inclusive). Verdicts with a partition time
+// earlier than this time should not be included in the results.
+func queryStartTime(intervals []interval) time.Time {
+	return intervals[len(intervals)-1].startTime
+}
+
+// queryEndTime returns the end of the partition time range that should be
+// queried (exclusive). Verdicts with partition times later than (or equal to)
+// this time should not be included in the results.
+func queryEndTime(intervals []interval) time.Time {
+	return intervals[0].endTime
+}
+
+func toPBIntervals(intervals []interval) []*pb.QueryTestVariantFailureRateResponse_Interval {
+	result := make([]*pb.QueryTestVariantFailureRateResponse_Interval, 0, len(intervals))
+	for i, iv := range intervals {
+		result = append(result, &pb.QueryTestVariantFailureRateResponse_Interval{
+			IntervalAge: int32(i + 1),
+			StartTime:   timestamppb.New(iv.startTime),
+			EndTime:     timestamppb.New(iv.endTime),
+		})
+	}
+	return result
+}
+
+// intervalStats represents time interval data returned by the
+// QueryFailureRate query. Each interval represents 24 hours of data.
+type intervalStats struct {
+	// DaysSinceQueryStart is the time interval bucket represented by this
+	// row.
+	// Interval 0 represents the first 24 hours of the partition time range
+	// queried, interval 1 is the the second 24 hours, and so on.
+	DaysSinceQueryStart int64
+	// TotalRunExpectedVerdicts is the number of verdicts which had only
+	// expected runs. An expected run is a run in which at least one test
+	// result was expected (excluding skips).
+	TotalRunExpectedVerdicts int64
+	// TotalRunFlakyVerdicts is the number of verdicts which had both expected
+	// and unexpected runs. An expected run is a run in which at least one
+	// test result was expected (excluding skips). An unexpected run is a run
+	// in which all test results are unexpected (excluding skips).
+	TotalRunFlakyVerdicts int64
+	// TotalRunExpectedVerdicts is the number of verdicts which had only
+	// unexpected runs. An unexpected run is a run in which
+	// al test results are unexpected (excluding skips).
+	TotalRunUnexpectedVerdicts int64
+}
+
+func toPBIntervalStats(stats []*intervalStats, intervals []interval) []*pb.TestVariantFailureRateAnalysis_IntervalStats {
+	queryStartTime := queryStartTime(intervals)
+	queryEndTime := queryEndTime(intervals)
+
+	results := make([]*pb.TestVariantFailureRateAnalysis_IntervalStats, 0, len(intervals))
+	// Ensure every interval is included in the output, even if there are
+	// no verdicts in it.
+	for i := range intervals {
+		results = append(results, &pb.TestVariantFailureRateAnalysis_IntervalStats{
+			IntervalAge: int32(i + 1),
+		})
+	}
+	for _, s := range stats {
+		// Match the interval data returned by the SQL query to the intervals
+		// returned by the RPC. The SQL query returns one interval per
+		// rolling 24 hour period, whereas the RPC returns one interval per
+		// rolling 24 weekday hour period (including any weekend included
+		// in that range). In practice, this means weekend data needs to get
+		// summed into the 24 weekday hour period that includes the Friday.
+
+		// Calculate the time interval represented by the interval data
+		// returned by the query.
+		intervalStartTime := queryStartTime.Add(time.Duration(s.DaysSinceQueryStart) * 24 * time.Hour)
+		intervalEndTime := intervalStartTime.Add(24 * time.Hour)
+		if queryEndTime.Before(intervalEndTime) {
+			intervalEndTime = queryEndTime
+		}
+
+		// Find the output interval that contains the query interval.
+		intervalIndex := -1
+		for i, iv := range intervals {
+			if !intervalEndTime.After(iv.endTime) && !intervalStartTime.Before(iv.startTime) {
+				intervalIndex = i
+				break
+			}
+		}
+		if intervalIndex == -1 {
+			// This should never happen.
+			panic("could not reconcile query intervals with output intervals")
+		}
+
+		results[intervalIndex].TotalRunExpectedVerdicts += int32(s.TotalRunExpectedVerdicts)
+		results[intervalIndex].TotalRunFlakyVerdicts += int32(s.TotalRunFlakyVerdicts)
+		results[intervalIndex].TotalRunUnexpectedVerdicts += int32(s.TotalRunUnexpectedVerdicts)
+	}
+	return results
+}
+
+// verdictExample is used to store an example verdict returned by
+// a Spanner query.
+type verdictExample struct {
+	PartitionTime        time.Time
+	IngestedInvocationId string
+	ChangelistHosts      []string
+	ChangelistChanges    []int64
+	ChangelistPatchsets  []int64
+}
+
+func toPBVerdictExamples(ves []*verdictExample) []*pb.TestVariantFailureRateAnalysis_VerdictExample {
+	results := make([]*pb.TestVariantFailureRateAnalysis_VerdictExample, 0, len(ves))
+	for _, ve := range ves {
+		cls := make([]*pb.Changelist, 0, len(ve.ChangelistHosts))
+		if len(ve.ChangelistHosts) != len(ve.ChangelistChanges) ||
+			len(ve.ChangelistChanges) != len(ve.ChangelistPatchsets) {
+			panic("data consistency issue: length of changelist arrays do not match")
+		}
+		for i := range ve.ChangelistHosts {
+			cls = append(cls, &pb.Changelist{
+				Host:     ve.ChangelistHosts[i] + GerritHostnameSuffix,
+				Change:   ve.ChangelistChanges[i],
+				Patchset: int32(ve.ChangelistPatchsets[i]),
+			})
+		}
+		results = append(results, &pb.TestVariantFailureRateAnalysis_VerdictExample{
+			PartitionTime:        timestamppb.New(ve.PartitionTime),
+			IngestedInvocationId: ve.IngestedInvocationId,
+			Changelists:          cls,
+		})
+	}
+	return results
+}
+
+// recentVerdict represents one of the most recent verdicts for the test variant.
+type recentVerdict struct {
+	verdictExample
+	HasUnexpectedRun bool
+}
+
+func toPBRecentVerdicts(verdicts []*recentVerdict) []*pb.TestVariantFailureRateAnalysis_RecentVerdict {
+	results := make([]*pb.TestVariantFailureRateAnalysis_RecentVerdict, 0, len(verdicts))
+	for _, v := range verdicts {
+		cls := make([]*pb.Changelist, 0, len(v.ChangelistHosts))
+		if len(v.ChangelistHosts) != len(v.ChangelistChanges) ||
+			len(v.ChangelistChanges) != len(v.ChangelistPatchsets) {
+			panic("data consistency issue: length of changelist arrays do not match")
+		}
+		for i := range v.ChangelistHosts {
+			cls = append(cls, &pb.Changelist{
+				Host:     v.ChangelistHosts[i] + GerritHostnameSuffix,
+				Change:   v.ChangelistChanges[i],
+				Patchset: int32(v.ChangelistPatchsets[i]),
+			})
+		}
+		results = append(results, &pb.TestVariantFailureRateAnalysis_RecentVerdict{
+			PartitionTime:        timestamppb.New(v.PartitionTime),
+			IngestedInvocationId: v.IngestedInvocationId,
+			Changelists:          cls,
+			HasUnexpectedRuns:    v.HasUnexpectedRun,
+		})
+	}
+	return results
+}
+
+var failureRateQueryTmpl = template.Must(template.New("").Parse(`
+WITH test_variant_verdicts AS (
+	SELECT
+		Index,
+		TestId,
+		VariantHash,
+		ARRAY(
+			-- Filter verdicts to at most one per unsubmitted changelist under
+			-- test. Don't filter verdicts without an unsubmitted changelist
+			-- under test (i.e. CI data).
+			SELECT
+				ANY_VALUE(STRUCT(
+				PartitionTime,
+				IngestedInvocationId,
+				HasUnexpectedRun,
+				HasExpectedRun,
+				ChangelistHosts,
+				ChangelistChanges,
+				ChangelistPatchsets,
+				PresubmitRunByAutomation)
+				-- Prefer the verdict that is flaky. If both (or neither) are flaky,
+				-- pick the verdict with the highest partition time. If partition
+				-- times are also the same, pick any.
+				HAVING MAX IF(HasExpectedRun AND HasUnexpectedRun, TIMESTAMP_ADD(PartitionTime, INTERVAL 365 DAY), PartitionTime)) AS Verdict,
+			FROM (
+				-- Flatten test runs to test verdicts.
+				SELECT
+					PartitionTime,
+					IngestedInvocationId,
+					LOGICAL_OR(UnexpectedRun) AS HasUnexpectedRun,
+					LOGICAL_OR(NOT UnexpectedRun) AS HasExpectedRun,
+					ANY_VALUE(ChangelistHosts) AS ChangelistHosts,
+					ANY_VALUE(ChangelistChanges) AS ChangelistChanges,
+					ANY_VALUE(ChangelistPatchsets) AS ChangelistPatchsets,
+					ANY_VALUE(PresubmitRunByAutomation) As PresubmitRunByAutomation
+				FROM (
+					-- Flatten test results to test runs.
+					SELECT
+						PartitionTime,
+						IngestedInvocationId,
+						RunIndex,
+						LOGICAL_AND(COALESCE(IsUnexpected, FALSE)) AS UnexpectedRun,
+						ANY_VALUE(ChangelistHosts) AS ChangelistHosts,
+						ANY_VALUE(ChangelistChanges) AS ChangelistChanges,
+						ANY_VALUE(ChangelistPatchsets) AS ChangelistPatchsets,
+						ANY_VALUE(PresubmitRunOwner IS NOT NULL AND PresubmitRunOwner = "automation") AS PresubmitRunByAutomation
+					FROM TestResults
+					WHERE Project = @project
+						AND PartitionTime >= @afterPartitionTime
+						AND PartitionTime < @beforePartitionTime
+						AND TestId = tv.TestId And VariantHash = tv.VariantHash
+						-- Exclude skipped results.
+						AND Status <> @skip
+						-- Exclude test results testing multiple CLs, as
+						-- we cannot ensure at most one verdict per CL for
+						-- them.
+						AND (ChangelistHosts IS NULL OR ARRAY_LENGTH(ChangelistHosts) <= 1)
+					GROUP BY PartitionTime, IngestedInvocationId, RunIndex
+				)
+				GROUP BY PartitionTime, IngestedInvocationId
+				ORDER BY PartitionTime DESC, IngestedInvocationId
+			)
+			-- Unique CL (if there is a CL under test).
+			GROUP BY
+				IF(ChangelistHosts IS NOT NULL AND ARRAY_LENGTH(ChangelistHosts) > 0, ChangelistHosts[OFFSET(0)], IngestedInvocationId),
+				IF(ChangelistHosts IS NOT NULL AND ARRAY_LENGTH(ChangelistHosts) > 0, ChangelistChanges[OFFSET(0)], NULL)
+			ORDER BY Verdict.PartitionTime DESC, Verdict.IngestedInvocationId
+		) AS Verdicts,
+	FROM UNNEST(@testVariants) tv WITH OFFSET Index
+)
+
+SELECT
+	TestId,
+	VariantHash,
+	ARRAY(
+		SELECT AS STRUCT
+			TIMESTAMP_DIFF(v.PartitionTime, @afterPartitionTime, DAY) as DaysSinceQueryStart,
+			COUNTIF(NOT v.HasUnexpectedRun AND v.HasExpectedRun) AS TotalRunExpectedVerdicts,
+			COUNTIF(v.HasUnexpectedRun AND v.HasExpectedRun) AS TotalRunFlakyVerdicts,
+			COUNTIF(v.HasUnexpectedRun AND NOT v.HasExpectedRun) AS TotalRunUnexpectedVerdicts
+		FROM UNNEST(Verdicts) v
+		GROUP BY DaysSinceQueryStart
+		ORDER BY DaysSinceQueryStart DESC
+	) As IntervalStats,
+	ARRAY(
+		SELECT AS STRUCT
+			v.PartitionTime,
+			v.IngestedInvocationId,
+			v.ChangelistHosts,
+			v.ChangelistChanges,
+			v.ChangelistPatchsets
+		FROM UNNEST(Verdicts) v WITH OFFSET o
+		WHERE v.HasUnexpectedRun AND v.HasExpectedRun
+		ORDER BY o -- Order by descending partition time.
+		LIMIT 10
+	) as RunFlakyExamples,
+	ARRAY(
+		SELECT AS STRUCT
+			v.PartitionTime,
+			v.IngestedInvocationId,
+			v.ChangelistHosts,
+			v.ChangelistChanges,
+			v.ChangelistPatchsets,
+			v.HasUnexpectedRun
+		FROM UNNEST(Verdicts) v WITH OFFSET o
+		WHERE
+			-- Filter out CLs authored by automation.
+			NOT PresubmitRunByAutomation
+		ORDER BY o
+		LIMIT 10
+	) as RecentVerdicts,
+FROM test_variant_verdicts
+ORDER BY Index
+`))
diff --git a/analysis/internal/testresults/query_failure_rate_test.go b/analysis/internal/testresults/query_failure_rate_test.go
new file mode 100644
index 0000000..84fd1b1
--- /dev/null
+++ b/analysis/internal/testresults/query_failure_rate_test.go
@@ -0,0 +1,192 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestQueryFailureRate(t *testing.T) {
+	Convey("QueryFailureRate", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+		var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+
+		err := CreateQueryFailureRateTestData(ctx)
+		So(err, ShouldBeNil)
+
+		project, asAtTime, tvs := QueryFailureRateSampleRequest()
+		opts := QueryFailureRateOptions{
+			Project:      project,
+			TestVariants: tvs,
+			AsAtTime:     asAtTime,
+		}
+		expectedResult := QueryFailureRateSampleResponse()
+		txn, cancel := span.ReadOnlyTransaction(ctx)
+		defer cancel()
+
+		Convey("Baseline", func() {
+			result, err := QueryFailureRate(txn, opts)
+			So(err, ShouldBeNil)
+			So(result, ShouldResembleProto, expectedResult)
+		})
+		Convey("Project filter works correctly", func() {
+			opts.Project = "none"
+			expectedResult.TestVariants = []*pb.TestVariantFailureRateAnalysis{
+				emptyAnalysis("test_id", var1),
+				emptyAnalysis("test_id", var3),
+			}
+
+			result, err := QueryFailureRate(txn, opts)
+			So(err, ShouldBeNil)
+			So(result, ShouldResembleProto, expectedResult)
+		})
+		Convey("Works for tests without data", func() {
+			notExistsVariant := pbutil.Variant("key1", "val1", "key2", "not_exists")
+			opts.TestVariants = append(opts.TestVariants,
+				&pb.TestVariantIdentifier{
+					TestId:  "not_exists_test_id",
+					Variant: var1,
+				},
+				&pb.TestVariantIdentifier{
+					TestId:  "test_id",
+					Variant: notExistsVariant,
+				})
+
+			expectedResult.TestVariants = append(expectedResult.TestVariants,
+				emptyAnalysis("not_exists_test_id", var1),
+				emptyAnalysis("test_id", notExistsVariant))
+
+			result, err := QueryFailureRate(txn, opts)
+			So(err, ShouldBeNil)
+			So(result, ShouldResembleProto, expectedResult)
+		})
+		Convey("Batching works correctly", func() {
+			// Ensure the order of test variants in the request and response
+			// remain correct even when there are multiple batches.
+			var expandedInput []*pb.TestVariantIdentifier
+			var expectedOutput []*pb.TestVariantFailureRateAnalysis
+			for i := 0; i < batchSize; i++ {
+				testID := fmt.Sprintf("test_id_%v", i)
+				expandedInput = append(expandedInput, &pb.TestVariantIdentifier{
+					TestId:  testID,
+					Variant: var1,
+				})
+				expectedOutput = append(expectedOutput, emptyAnalysis(testID, var1))
+			}
+
+			expandedInput = append(expandedInput, tvs...)
+			expectedOutput = append(expectedOutput, expectedResult.TestVariants...)
+
+			opts.TestVariants = expandedInput
+			expectedResult.TestVariants = expectedOutput
+
+			result, err := QueryFailureRate(txn, opts)
+			So(err, ShouldBeNil)
+			So(result, ShouldResembleProto, expectedResult)
+		})
+	})
+}
+
+// emptyAnalysis returns an empty analysis proto with intervals populated.
+func emptyAnalysis(testId string, variant *pb.Variant) *pb.TestVariantFailureRateAnalysis {
+	return &pb.TestVariantFailureRateAnalysis{
+		TestId:  testId,
+		Variant: variant,
+		IntervalStats: []*pb.TestVariantFailureRateAnalysis_IntervalStats{
+			{IntervalAge: 1},
+			{IntervalAge: 2},
+			{IntervalAge: 3},
+			{IntervalAge: 4},
+			{IntervalAge: 5},
+		},
+		RunFlakyVerdictExamples: []*pb.TestVariantFailureRateAnalysis_VerdictExample{},
+		RecentVerdicts:          []*pb.TestVariantFailureRateAnalysis_RecentVerdict{},
+	}
+}
+
+func TestJumpBack24WeekdayHours(t *testing.T) {
+	Convey("jumpBack24WeekdayHours", t, func() {
+		// Expect jumpBack24WeekdayHours to go back in time just far enough
+		// that 24 workday hours are between the returned time and now.
+		Convey("Monday", func() {
+			// Given an input on a Monday (e.g. 14th of March 2022), expect
+			// failureRateQueryAfterTime to return the corresponding time
+			// on the previous Friday.
+
+			now := time.Date(2022, time.March, 14, 23, 59, 59, 999999999, time.UTC)
+			afterTime := jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, time.Date(2022, time.March, 11, 23, 59, 59, 999999999, time.UTC))
+
+			now = time.Date(2022, time.March, 14, 0, 0, 0, 0, time.UTC)
+			afterTime = jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, time.Date(2022, time.March, 11, 0, 0, 0, 0, time.UTC))
+		})
+		Convey("Sunday", func() {
+			// Given a time on a Sunday (e.g. 13th of March 2022), expect
+			// failureRateQueryAfterTime to return the start of the previous
+			// Friday.
+			startOfFriday := time.Date(2022, time.March, 11, 0, 0, 0, 0, time.UTC)
+
+			now := time.Date(2022, time.March, 13, 23, 59, 59, 999999999, time.UTC)
+			afterTime := jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, startOfFriday)
+
+			now = time.Date(2022, time.March, 13, 0, 0, 0, 0, time.UTC)
+			afterTime = jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, startOfFriday)
+		})
+		Convey("Saturday", func() {
+			// Given a time on a Saturday (e.g. 12th of March 2022), expect
+			// failureRateQueryAfterTime to return the start of the previous
+			// Friday.
+			startOfFriday := time.Date(2022, time.March, 11, 0, 0, 0, 0, time.UTC)
+
+			now := time.Date(2022, time.March, 12, 23, 59, 59, 999999999, time.UTC)
+			afterTime := jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, startOfFriday)
+
+			now = time.Date(2022, time.March, 12, 0, 0, 0, 0, time.UTC)
+			afterTime = jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, startOfFriday)
+		})
+		Convey("Tuesday to Friday", func() {
+			// Given an input on a Tuesday (e.g. 15th of March 2022), expect
+			// failureRateQueryAfterTime to return the corresponding time
+			// the previous day.
+			now := time.Date(2022, time.March, 15, 1, 2, 3, 4, time.UTC)
+			afterTime := jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, time.Date(2022, time.March, 14, 1, 2, 3, 4, time.UTC))
+
+			// Given an input on a Friday (e.g. 18th of March 2022), expect
+			// failureRateQueryAfterTime to return the corresponding time
+			// the previous day.
+			now = time.Date(2022, time.March, 18, 1, 2, 3, 4, time.UTC)
+			afterTime = jumpBack24WeekdayHours(now)
+			So(afterTime, ShouldEqual, time.Date(2022, time.March, 17, 1, 2, 3, 4, time.UTC))
+		})
+	})
+}
diff --git a/analysis/internal/testresults/span.go b/analysis/internal/testresults/span.go
new file mode 100644
index 0000000..8099a4b
--- /dev/null
+++ b/analysis/internal/testresults/span.go
@@ -0,0 +1,1014 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"context"
+	"sort"
+	"text/template"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	"go.chromium.org/luci/analysis/internal/pagination"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const pageTokenTimeFormat = time.RFC3339Nano
+
+// The suffix used for all gerrit hostnames.
+const GerritHostnameSuffix = "-review.googlesource.com"
+
+var (
+	// minTimestamp is the minimum Timestamp value in Spanner.
+	// https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#timestamp_type
+	minSpannerTimestamp = time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
+	// maxSpannerTimestamp is the max Timestamp value in Spanner.
+	// https://cloud.google.com/spanner/docs/reference/standard-sql/data-types#timestamp_type
+	maxSpannerTimestamp = time.Date(9999, time.December, 31, 23, 59, 59, 999999999, time.UTC)
+)
+
+// Changelist represents a gerrit changelist.
+type Changelist struct {
+	// Host is the gerrit hostname, excluding "-review.googlesource.com".
+	Host     string
+	Change   int64
+	Patchset int64
+}
+
+// PresubmitRun represents information about the presubmit run a test result
+// was part of.
+type PresubmitRun struct {
+	Owner string
+	Mode  pb.PresubmitRunMode
+}
+
+// SortChangelists sorts a slice of changelists to be in ascending
+// lexicographical order by (host, change, patchset).
+func SortChangelists(cls []Changelist) {
+	sort.Slice(cls, func(i, j int) bool {
+		// Returns true iff cls[i] is less than cls[j].
+		if cls[i].Host < cls[j].Host {
+			return true
+		}
+		if cls[i].Host == cls[j].Host && cls[i].Change < cls[j].Change {
+			return true
+		}
+		if cls[i].Host == cls[j].Host && cls[i].Change == cls[j].Change && cls[i].Patchset < cls[j].Patchset {
+			return true
+		}
+		return false
+	})
+}
+
+// IngestedInvocation represents a row in the IngestedInvocations table.
+type IngestedInvocation struct {
+	Project string
+	// IngestedInvocationID is the ID of the (root) ResultDB invocation
+	// being ingested, excluding "invocations/".
+	IngestedInvocationID string
+	SubRealm             string
+	PartitionTime        time.Time
+	BuildStatus          pb.BuildStatus
+	PresubmitRun         *PresubmitRun
+
+	// The following fields describe the commit tested, excluding any
+	// unsubmitted changelists. If information is not available,
+	// CommitPosition is zero and the other fields are their default
+	// values.
+	GitReferenceHash []byte
+	CommitPosition   int64
+	CommitHash       string
+
+	// The unsubmitted changelists tested (if any). Limited to
+	// at most 10 changelists.
+	Changelists []Changelist
+}
+
+// ReadIngestedInvocations read ingested invocations from the
+// IngestedInvocations table.
+// Must be called in a spanner transactional context.
+func ReadIngestedInvocations(ctx context.Context, keys spanner.KeySet, fn func(inv *IngestedInvocation) error) error {
+	var b spanutil.Buffer
+	fields := []string{
+		"Project", "IngestedInvocationId", "SubRealm", "PartitionTime",
+		"BuildStatus",
+		"PresubmitRunMode", "PresubmitRunOwner",
+		"GitReferenceHash", "CommitPosition", "CommitHash",
+		"ChangelistHosts", "ChangelistChanges", "ChangelistPatchsets",
+	}
+	return span.Read(ctx, "IngestedInvocations", keys, fields).Do(
+		func(row *spanner.Row) error {
+			inv := &IngestedInvocation{}
+			var presubmitRunMode spanner.NullInt64
+			var presubmitRunOwner spanner.NullString
+			var gitReferenceHash []byte
+			var commitPosition spanner.NullInt64
+			var commitHash spanner.NullString
+			var changelistHosts []string
+			var changelistChanges []int64
+			var changelistPatchsets []int64
+
+			err := b.FromSpanner(
+				row,
+				&inv.Project, &inv.IngestedInvocationID, &inv.SubRealm, &inv.PartitionTime,
+				&inv.BuildStatus,
+				&presubmitRunMode, &presubmitRunOwner,
+				&gitReferenceHash, &commitPosition, &commitHash,
+				&changelistHosts, &changelistChanges, &changelistPatchsets,
+			)
+			if err != nil {
+				return err
+			}
+
+			// Data in Spanner should be consistent, so presubmitRunMode.Valid ==
+			//   presubmitRunOwner.Valid.
+			if presubmitRunMode.Valid {
+				inv.PresubmitRun = &PresubmitRun{
+					Mode:  pb.PresubmitRunMode(presubmitRunMode.Int64),
+					Owner: presubmitRunOwner.StringVal,
+				}
+			}
+
+			// Data in Spanner should be consistent, so commitPosition.Valid ==
+			// commitHash.Valid == (gitReferenceHash != nil).
+			if commitPosition.Valid {
+				inv.GitReferenceHash = gitReferenceHash
+				inv.CommitPosition = commitPosition.Int64
+				inv.CommitHash = commitHash.StringVal
+			}
+
+			// Data in Spanner should be consistent, so
+			// len(changelistHosts) == len(changelistChanges)
+			//    == len(changelistPatchsets).
+			if len(changelistHosts) != len(changelistChanges) ||
+				len(changelistChanges) != len(changelistPatchsets) {
+				panic("Changelist arrays have mismatched length in Spanner")
+			}
+			changelists := make([]Changelist, 0, len(changelistHosts))
+			for i := range changelistHosts {
+				changelists = append(changelists, Changelist{
+					Host:     changelistHosts[i],
+					Change:   changelistChanges[i],
+					Patchset: changelistPatchsets[i],
+				})
+			}
+			inv.Changelists = changelists
+			return fn(inv)
+		})
+}
+
+// SaveUnverified returns a mutation to insert the ingested invocation into
+// the IngestedInvocations table. The ingested invocation is not validated.
+func (inv *IngestedInvocation) SaveUnverified() *spanner.Mutation {
+	var presubmitRunMode spanner.NullInt64
+	var presubmitRunOwner spanner.NullString
+	if inv.PresubmitRun != nil {
+		presubmitRunMode = spanner.NullInt64{Valid: true, Int64: int64(inv.PresubmitRun.Mode)}
+		presubmitRunOwner = spanner.NullString{Valid: true, StringVal: inv.PresubmitRun.Owner}
+	}
+
+	var gitReferenceHash []byte
+	var commitPosition spanner.NullInt64
+	var commitHash spanner.NullString
+	if inv.CommitPosition > 0 {
+		gitReferenceHash = inv.GitReferenceHash
+		commitPosition = spanner.NullInt64{Valid: true, Int64: inv.CommitPosition}
+		commitHash = spanner.NullString{Valid: true, StringVal: inv.CommitHash}
+	}
+
+	changelistHosts := make([]string, 0, len(inv.Changelists))
+	changelistChanges := make([]int64, 0, len(inv.Changelists))
+	changelistPatchsets := make([]int64, 0, len(inv.Changelists))
+	for _, cl := range inv.Changelists {
+		changelistHosts = append(changelistHosts, cl.Host)
+		changelistChanges = append(changelistChanges, cl.Change)
+		changelistPatchsets = append(changelistPatchsets, cl.Patchset)
+	}
+
+	row := map[string]interface{}{
+		"Project":              inv.Project,
+		"IngestedInvocationId": inv.IngestedInvocationID,
+		"SubRealm":             inv.SubRealm,
+		"PartitionTime":        inv.PartitionTime,
+		"BuildStatus":          inv.BuildStatus,
+		"GitReferenceHash":     gitReferenceHash,
+		"CommitPosition":       commitPosition,
+		"CommitHash":           commitHash,
+		"PresubmitRunMode":     presubmitRunMode,
+		"PresubmitRunOwner":    presubmitRunOwner,
+		"ChangelistHosts":      changelistHosts,
+		"ChangelistChanges":    changelistChanges,
+		"ChangelistPatchsets":  changelistPatchsets,
+	}
+	return spanner.InsertOrUpdateMap("IngestedInvocations", spanutil.ToSpannerMap(row))
+}
+
+// TestResult represents a row in the TestResults table.
+type TestResult struct {
+	Project              string
+	TestID               string
+	PartitionTime        time.Time
+	VariantHash          string
+	IngestedInvocationID string
+	RunIndex             int64
+	ResultIndex          int64
+	IsUnexpected         bool
+	RunDuration          *time.Duration
+	Status               pb.TestResultStatus
+	// Properties of the test variant in the invocation (stored denormalised) follow.
+	ExonerationReasons []pb.ExonerationReason
+	// Properties of the invocation (stored denormalised) follow.
+	SubRealm         string
+	BuildStatus      pb.BuildStatus
+	PresubmitRun     *PresubmitRun
+	GitReferenceHash []byte
+	CommitPosition   int64
+	Changelists      []Changelist
+}
+
+// ReadTestResults reads test results from the TestResults table.
+// Must be called in a spanner transactional context.
+func ReadTestResults(ctx context.Context, keys spanner.KeySet, fn func(tr *TestResult) error) error {
+	var b spanutil.Buffer
+	fields := []string{
+		"Project", "TestId", "PartitionTime", "VariantHash", "IngestedInvocationId",
+		"RunIndex", "ResultIndex",
+		"IsUnexpected", "RunDurationUsec", "Status",
+		"ExonerationReasons",
+		"SubRealm", "BuildStatus",
+		"PresubmitRunMode", "PresubmitRunOwner",
+		"GitReferenceHash", "CommitPosition",
+		"ChangelistHosts", "ChangelistChanges", "ChangelistPatchsets",
+	}
+	return span.Read(ctx, "TestResults", keys, fields).Do(
+		func(row *spanner.Row) error {
+			tr := &TestResult{}
+			var runDurationUsec spanner.NullInt64
+			var isUnexpected spanner.NullBool
+			var presubmitRunMode spanner.NullInt64
+			var presubmitRunOwner spanner.NullString
+			var gitReferenceHash []byte
+			var commitPosition spanner.NullInt64
+			var changelistHosts []string
+			var changelistChanges []int64
+			var changelistPatchsets []int64
+			err := b.FromSpanner(
+				row,
+				&tr.Project, &tr.TestID, &tr.PartitionTime, &tr.VariantHash, &tr.IngestedInvocationID,
+				&tr.RunIndex, &tr.ResultIndex,
+				&isUnexpected, &runDurationUsec, &tr.Status,
+				&tr.ExonerationReasons,
+				&tr.SubRealm, &tr.BuildStatus,
+				&presubmitRunMode, &presubmitRunOwner,
+				&gitReferenceHash, &commitPosition,
+				&changelistHosts, &changelistChanges, &changelistPatchsets,
+			)
+			if err != nil {
+				return err
+			}
+			if runDurationUsec.Valid {
+				runDuration := time.Microsecond * time.Duration(runDurationUsec.Int64)
+				tr.RunDuration = &runDuration
+			}
+			tr.IsUnexpected = isUnexpected.Valid && isUnexpected.Bool
+
+			// Data in Spanner should be consistent, so presubmitRunMode.Valid ==
+			//   presubmitRunOwner.Valid.
+			if presubmitRunMode.Valid {
+				tr.PresubmitRun = &PresubmitRun{
+					Mode:  pb.PresubmitRunMode(presubmitRunMode.Int64),
+					Owner: presubmitRunOwner.StringVal,
+				}
+			}
+
+			// Data in Spanner should be consistent, so commitPosition.Valid ==
+			// (gitReferenceHash != nil).
+			if commitPosition.Valid {
+				tr.GitReferenceHash = gitReferenceHash
+				tr.CommitPosition = commitPosition.Int64
+			}
+
+			// Data in spanner should be consistent, so
+			// len(changelistHosts) == len(changelistChanges)
+			//    == len(changelistPatchsets).
+			if len(changelistHosts) != len(changelistChanges) ||
+				len(changelistChanges) != len(changelistPatchsets) {
+				panic("Changelist arrays have mismatched length in Spanner")
+			}
+			changelists := make([]Changelist, 0, len(changelistHosts))
+			for i := range changelistHosts {
+				changelists = append(changelists, Changelist{
+					Host:     changelistHosts[i],
+					Change:   changelistChanges[i],
+					Patchset: changelistPatchsets[i],
+				})
+			}
+			tr.Changelists = changelists
+			return fn(tr)
+		})
+}
+
+// TestResultSaveCols is the set of columns written to in a test result save.
+// Allocated here once to avoid reallocating on every test result save.
+var TestResultSaveCols = []string{
+	"Project", "TestId", "PartitionTime", "VariantHash",
+	"IngestedInvocationId", "RunIndex", "ResultIndex",
+	"IsUnexpected", "RunDurationUsec", "Status",
+	"ExonerationReasons", "SubRealm", "BuildStatus",
+	"PresubmitRunMode", "PresubmitRunOwner",
+	"GitReferenceHash", "CommitPosition",
+	"ChangelistHosts", "ChangelistChanges", "ChangelistPatchsets",
+}
+
+// SaveUnverified prepare a mutation to insert the test result into the
+// TestResults table. The test result is not validated.
+func (tr *TestResult) SaveUnverified() *spanner.Mutation {
+	var runDurationUsec spanner.NullInt64
+	if tr.RunDuration != nil {
+		runDurationUsec.Int64 = tr.RunDuration.Microseconds()
+		runDurationUsec.Valid = true
+	}
+
+	var presubmitRunMode spanner.NullInt64
+	var presubmitRunOwner spanner.NullString
+	if tr.PresubmitRun != nil {
+		presubmitRunMode = spanner.NullInt64{Valid: true, Int64: int64(tr.PresubmitRun.Mode)}
+		presubmitRunOwner = spanner.NullString{Valid: true, StringVal: tr.PresubmitRun.Owner}
+	}
+
+	var gitReferenceHash []byte
+	var commitPosition spanner.NullInt64
+	if tr.CommitPosition > 0 {
+		gitReferenceHash = tr.GitReferenceHash
+		commitPosition = spanner.NullInt64{Valid: true, Int64: tr.CommitPosition}
+	}
+
+	changelistHosts := make([]string, 0, len(tr.Changelists))
+	changelistChanges := make([]int64, 0, len(tr.Changelists))
+	changelistPatchsets := make([]int64, 0, len(tr.Changelists))
+	for _, cl := range tr.Changelists {
+		changelistHosts = append(changelistHosts, cl.Host)
+		changelistChanges = append(changelistChanges, cl.Change)
+		changelistPatchsets = append(changelistPatchsets, cl.Patchset)
+	}
+
+	isUnexpected := spanner.NullBool{Bool: tr.IsUnexpected, Valid: tr.IsUnexpected}
+
+	exonerationReasons := tr.ExonerationReasons
+	if len(exonerationReasons) == 0 {
+		// Store absence of exonerations as a NULL value in the database
+		// rather than an empty array. Backfilling the column is too
+		// time consuming and NULLs use slightly less storage space.
+		exonerationReasons = nil
+	}
+
+	// Specify values in a slice directly instead of
+	// creating a map and using spanner.InsertOrUpdateMap.
+	// Profiling revealed ~15% of all CPU cycles spent
+	// ingesting test results were wasted generating a
+	// map and converting it back to the slice
+	// needed for a *spanner.Mutation using InsertOrUpdateMap.
+	// Ingestion appears to be CPU bound at times.
+	vals := []interface{}{
+		tr.Project, tr.TestID, tr.PartitionTime, tr.VariantHash,
+		tr.IngestedInvocationID, tr.RunIndex, tr.ResultIndex,
+		isUnexpected, runDurationUsec, int64(tr.Status),
+		spanutil.ToSpanner(exonerationReasons), tr.SubRealm, int64(tr.BuildStatus),
+		presubmitRunMode, presubmitRunOwner,
+		gitReferenceHash, commitPosition,
+		changelistHosts, changelistChanges, changelistPatchsets,
+	}
+	return spanner.InsertOrUpdate("TestResults", TestResultSaveCols, vals)
+}
+
+// ReadTestHistoryOptions specifies options for ReadTestHistory().
+type ReadTestHistoryOptions struct {
+	Project          string
+	TestID           string
+	SubRealms        []string
+	VariantPredicate *pb.VariantPredicate
+	SubmittedFilter  pb.SubmittedFilter
+	TimeRange        *pb.TimeRange
+	PageSize         int
+	PageToken        string
+}
+
+// statement generates a spanner statement for the specified query template.
+func (opts ReadTestHistoryOptions) statement(ctx context.Context, tmpl string, paginationParams []string) (spanner.Statement, error) {
+	params := map[string]interface{}{
+		"project":   opts.Project,
+		"testId":    opts.TestID,
+		"subRealms": opts.SubRealms,
+		"limit":     opts.PageSize,
+
+		// If the filter is unspecified, this param will be ignored during the
+		// statement generation step.
+		"hasUnsubmittedChanges": opts.SubmittedFilter == pb.SubmittedFilter_ONLY_UNSUBMITTED,
+
+		// Verdict status enum values.
+		"unexpected":          int(pb.TestVerdictStatus_UNEXPECTED),
+		"unexpectedlySkipped": int(pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED),
+		"flaky":               int(pb.TestVerdictStatus_FLAKY),
+		"exonerated":          int(pb.TestVerdictStatus_EXONERATED),
+		"expected":            int(pb.TestVerdictStatus_EXPECTED),
+
+		// Test result status enum values.
+		"skip": int(pb.TestResultStatus_SKIP),
+		"pass": int(pb.TestResultStatus_PASS),
+	}
+	input := map[string]interface{}{
+		"hasLimit":           opts.PageSize > 0,
+		"hasSubmittedFilter": opts.SubmittedFilter != pb.SubmittedFilter_SUBMITTED_FILTER_UNSPECIFIED,
+		"pagination":         opts.PageToken != "",
+		"params":             params,
+	}
+
+	if opts.TimeRange.GetEarliest() != nil {
+		params["afterTime"] = opts.TimeRange.GetEarliest().AsTime()
+	} else {
+		params["afterTime"] = minSpannerTimestamp
+	}
+	if opts.TimeRange.GetLatest() != nil {
+		params["beforeTime"] = opts.TimeRange.GetLatest().AsTime()
+	} else {
+		params["beforeTime"] = maxSpannerTimestamp
+	}
+
+	switch p := opts.VariantPredicate.GetPredicate().(type) {
+	case *pb.VariantPredicate_Equals:
+		input["hasVariantHash"] = true
+		params["variantHash"] = pbutil.VariantHash(p.Equals)
+	case *pb.VariantPredicate_Contains:
+		if len(p.Contains.Def) > 0 {
+			input["hasVariantKVs"] = true
+			params["variantKVs"] = pbutil.VariantToStrings(p.Contains)
+		}
+	case *pb.VariantPredicate_HashEquals:
+		input["hasVariantHash"] = true
+		params["variantHash"] = p.HashEquals
+	case nil:
+		// No filter.
+	default:
+		panic(errors.Reason("unexpected variant predicate %q", opts.VariantPredicate).Err())
+	}
+
+	if opts.PageToken != "" {
+		tokens, err := pagination.ParseToken(opts.PageToken)
+		if err != nil {
+			return spanner.Statement{}, err
+		}
+
+		if len(tokens) != len(paginationParams) {
+			return spanner.Statement{}, pagination.InvalidToken(errors.Reason("expected %d components, got %d", len(paginationParams), len(tokens)).Err())
+		}
+
+		// Keep all pagination params as strings and convert them to other data
+		// types in the query as necessary. So we can have a unified way of handling
+		// different page tokens.
+		for i, param := range paginationParams {
+			params[param] = tokens[i]
+		}
+	}
+
+	stmt, err := spanutil.GenerateStatement(testHistoryQueryTmpl, tmpl, input)
+	if err != nil {
+		return spanner.Statement{}, err
+	}
+	stmt.Params = params
+
+	return stmt, nil
+}
+
+// ReadTestHistory reads verdicts from the spanner database.
+// Must be called in a spanner transactional context.
+func ReadTestHistory(ctx context.Context, opts ReadTestHistoryOptions) (verdicts []*pb.TestVerdict, nextPageToken string, err error) {
+	stmt, err := opts.statement(ctx, "testHistoryQuery", []string{"paginationTime", "paginationVariantHash", "paginationInvId"})
+	if err != nil {
+		return nil, "", err
+	}
+
+	var b spanutil.Buffer
+	verdicts = make([]*pb.TestVerdict, 0, opts.PageSize)
+	err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error {
+		tv := &pb.TestVerdict{
+			TestId: opts.TestID,
+		}
+		var status int64
+		var passedAvgDurationUsec spanner.NullInt64
+		err := b.FromSpanner(
+			row,
+			&tv.PartitionTime,
+			&tv.VariantHash,
+			&tv.InvocationId,
+			&status,
+			&passedAvgDurationUsec,
+		)
+		if err != nil {
+			return err
+		}
+		tv.Status = pb.TestVerdictStatus(status)
+		if passedAvgDurationUsec.Valid {
+			tv.PassedAvgDuration = durationpb.New(time.Microsecond * time.Duration(passedAvgDurationUsec.Int64))
+		}
+		verdicts = append(verdicts, tv)
+		return nil
+	})
+	if err != nil {
+		return nil, "", errors.Annotate(err, "query test history").Err()
+	}
+
+	if opts.PageSize != 0 && len(verdicts) == opts.PageSize {
+		lastTV := verdicts[len(verdicts)-1]
+		nextPageToken = pagination.Token(lastTV.PartitionTime.AsTime().Format(pageTokenTimeFormat), lastTV.VariantHash, lastTV.InvocationId)
+	}
+	return verdicts, nextPageToken, nil
+}
+
+// ReadTestHistoryStats reads stats of verdicts grouped by UTC dates from the
+// spanner database.
+// Must be called in a spanner transactional context.
+func ReadTestHistoryStats(ctx context.Context, opts ReadTestHistoryOptions) (groups []*pb.QueryTestHistoryStatsResponse_Group, nextPageToken string, err error) {
+	stmt, err := opts.statement(ctx, "testHistoryStatsQuery", []string{"paginationDate", "paginationVariantHash"})
+	if err != nil {
+		return nil, "", err
+	}
+
+	var b spanutil.Buffer
+	groups = make([]*pb.QueryTestHistoryStatsResponse_Group, 0, opts.PageSize)
+	err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error {
+		group := &pb.QueryTestHistoryStatsResponse_Group{}
+		var (
+			unexpectedCount, unexpectedlySkippedCount  int64
+			flakyCount, exoneratedCount, expectedCount int64
+			passedAvgDurationUsec                      spanner.NullInt64
+		)
+		err := b.FromSpanner(
+			row,
+			&group.PartitionTime,
+			&group.VariantHash,
+			&unexpectedCount, &unexpectedlySkippedCount,
+			&flakyCount, &exoneratedCount, &expectedCount,
+			&passedAvgDurationUsec,
+		)
+		if err != nil {
+			return err
+		}
+		group.UnexpectedCount = int32(unexpectedCount)
+		group.UnexpectedlySkippedCount = int32(unexpectedlySkippedCount)
+		group.FlakyCount = int32(flakyCount)
+		group.ExoneratedCount = int32(exoneratedCount)
+		group.ExpectedCount = int32(expectedCount)
+		if passedAvgDurationUsec.Valid {
+			group.PassedAvgDuration = durationpb.New(time.Microsecond * time.Duration(passedAvgDurationUsec.Int64))
+		}
+		groups = append(groups, group)
+		return nil
+	})
+	if err != nil {
+		return nil, "", errors.Annotate(err, "query test history stats").Err()
+	}
+
+	if opts.PageSize != 0 && len(groups) == opts.PageSize {
+		lastGroup := groups[len(groups)-1]
+		nextPageToken = pagination.Token(lastGroup.PartitionTime.AsTime().Format(pageTokenTimeFormat), lastGroup.VariantHash)
+	}
+	return groups, nextPageToken, nil
+}
+
+// TestVariantRealm represents a row in the TestVariantRealm table.
+type TestVariantRealm struct {
+	Project           string
+	TestID            string
+	VariantHash       string
+	SubRealm          string
+	Variant           *pb.Variant
+	LastIngestionTime time.Time
+}
+
+// ReadTestVariantRealms read test variant realms from the TestVariantRealms
+// table.
+// Must be called in a spanner transactional context.
+func ReadTestVariantRealms(ctx context.Context, keys spanner.KeySet, fn func(tvr *TestVariantRealm) error) error {
+	var b spanutil.Buffer
+	fields := []string{"Project", "TestId", "VariantHash", "SubRealm", "Variant", "LastIngestionTime"}
+	return span.Read(ctx, "TestVariantRealms", keys, fields).Do(
+		func(row *spanner.Row) error {
+			tvr := &TestVariantRealm{}
+			err := b.FromSpanner(
+				row,
+				&tvr.Project,
+				&tvr.TestID,
+				&tvr.VariantHash,
+				&tvr.SubRealm,
+				&tvr.Variant,
+				&tvr.LastIngestionTime,
+			)
+			if err != nil {
+				return err
+			}
+			return fn(tvr)
+		})
+}
+
+// TestVariantRealmSaveCols is the set of columns written to in a test variant
+// realm save. Allocated here once to avoid reallocating on every save.
+var TestVariantRealmSaveCols = []string{
+	"Project", "TestId", "VariantHash", "SubRealm",
+	"Variant", "LastIngestionTime",
+}
+
+// SaveUnverified creates a mutation to save the test variant realm into
+// the TestVariantRealms table. The test variant realm is not verified.
+// Must be called in spanner RW transactional context.
+func (tvr *TestVariantRealm) SaveUnverified() *spanner.Mutation {
+	vals := []interface{}{
+		tvr.Project, tvr.TestID, tvr.VariantHash, tvr.SubRealm,
+		pbutil.VariantToStrings(tvr.Variant), tvr.LastIngestionTime,
+	}
+	return spanner.InsertOrUpdate("TestVariantRealms", TestVariantRealmSaveCols, vals)
+}
+
+// TestVariantRealm represents a row in the TestVariantRealm table.
+type TestRealm struct {
+	Project           string
+	TestID            string
+	SubRealm          string
+	LastIngestionTime time.Time
+}
+
+// ReadTestRealms read test variant realms from the TestRealms table.
+// Must be called in a spanner transactional context.
+func ReadTestRealms(ctx context.Context, keys spanner.KeySet, fn func(tr *TestRealm) error) error {
+	var b spanutil.Buffer
+	fields := []string{"Project", "TestId", "SubRealm", "LastIngestionTime"}
+	return span.Read(ctx, "TestRealms", keys, fields).Do(
+		func(row *spanner.Row) error {
+			tr := &TestRealm{}
+			err := b.FromSpanner(
+				row,
+				&tr.Project,
+				&tr.TestID,
+				&tr.SubRealm,
+				&tr.LastIngestionTime,
+			)
+			if err != nil {
+				return err
+			}
+			return fn(tr)
+		})
+}
+
+// TestRealmSaveCols is the set of columns written to in a test variant
+// realm save. Allocated here once to avoid reallocating on every save.
+var TestRealmSaveCols = []string{"Project", "TestId", "SubRealm", "LastIngestionTime"}
+
+// SaveUnverified creates a mutation to save the test realm into the TestRealms
+// table. The test realm is not verified.
+// Must be called in spanner RW transactional context.
+func (tvr *TestRealm) SaveUnverified() *spanner.Mutation {
+	vals := []interface{}{tvr.Project, tvr.TestID, tvr.SubRealm, tvr.LastIngestionTime}
+	return spanner.InsertOrUpdate("TestRealms", TestRealmSaveCols, vals)
+}
+
+// ReadVariantsOptions specifies options for ReadVariants().
+type ReadVariantsOptions struct {
+	SubRealms        []string
+	VariantPredicate *pb.VariantPredicate
+	PageSize         int
+	PageToken        string
+}
+
+// parseQueryVariantsPageToken parses the positions from the page token.
+func parseQueryVariantsPageToken(pageToken string) (afterHash string, err error) {
+	tokens, err := pagination.ParseToken(pageToken)
+	if err != nil {
+		return "", err
+	}
+
+	if len(tokens) != 1 {
+		return "", pagination.InvalidToken(errors.Reason("expected 1 components, got %d", len(tokens)).Err())
+	}
+
+	return tokens[0], nil
+}
+
+// ReadVariants reads all the variants of the specified test from the
+// spanner database.
+// Must be called in a spanner transactional context.
+func ReadVariants(ctx context.Context, project, testID string, opts ReadVariantsOptions) (variants []*pb.QueryVariantsResponse_VariantInfo, nextPageToken string, err error) {
+	paginationVariantHash := ""
+	if opts.PageToken != "" {
+		paginationVariantHash, err = parseQueryVariantsPageToken(opts.PageToken)
+		if err != nil {
+			return nil, "", err
+		}
+	}
+
+	params := map[string]interface{}{
+		"project":   project,
+		"testId":    testID,
+		"subRealms": opts.SubRealms,
+
+		// Control pagination.
+		"limit":                 opts.PageSize,
+		"paginationVariantHash": paginationVariantHash,
+	}
+	input := map[string]interface{}{
+		"hasLimit": opts.PageSize > 0,
+		"params":   params,
+	}
+
+	switch p := opts.VariantPredicate.GetPredicate().(type) {
+	case *pb.VariantPredicate_Equals:
+		input["hasVariantHash"] = true
+		params["variantHash"] = pbutil.VariantHash(p.Equals)
+	case *pb.VariantPredicate_Contains:
+		if len(p.Contains.Def) > 0 {
+			input["hasVariantKVs"] = true
+			params["variantKVs"] = pbutil.VariantToStrings(p.Contains)
+		}
+	case *pb.VariantPredicate_HashEquals:
+		input["hasVariantHash"] = true
+		params["variantHash"] = p.HashEquals
+	case nil:
+		// No filter.
+	default:
+		panic(errors.Reason("unexpected variant predicate %q", opts.VariantPredicate).Err())
+	}
+
+	stmt, err := spanutil.GenerateStatement(variantsQueryTmpl, variantsQueryTmpl.Name(), input)
+	if err != nil {
+		return nil, "", err
+	}
+	stmt.Params = params
+
+	var b spanutil.Buffer
+	variants = make([]*pb.QueryVariantsResponse_VariantInfo, 0, opts.PageSize)
+	err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error {
+		variant := &pb.QueryVariantsResponse_VariantInfo{}
+		err := b.FromSpanner(
+			row,
+			&variant.VariantHash,
+			&variant.Variant,
+		)
+		if err != nil {
+			return err
+		}
+		variants = append(variants, variant)
+		return nil
+	})
+	if err != nil {
+		return nil, "", err
+	}
+
+	if opts.PageSize != 0 && len(variants) == opts.PageSize {
+		lastVariant := variants[len(variants)-1]
+		nextPageToken = pagination.Token(lastVariant.VariantHash)
+	}
+	return variants, nextPageToken, nil
+}
+
+// QueryTestsOptions specifies options for QueryTests().
+type QueryTestsOptions struct {
+	SubRealms []string
+	PageSize  int
+	PageToken string
+}
+
+// parseQueryTestsPageToken parses the positions from the page token.
+func parseQueryTestsPageToken(pageToken string) (afterTestId string, err error) {
+	tokens, err := pagination.ParseToken(pageToken)
+	if err != nil {
+		return "", err
+	}
+
+	if len(tokens) != 1 {
+		return "", pagination.InvalidToken(errors.Reason("expected 1 components, got %d", len(tokens)).Err())
+	}
+
+	return tokens[0], nil
+}
+
+// QueryTests finds all the test IDs with the specified testIDSubstring from
+// the spanner database.
+// Must be called in a spanner transactional context.
+func QueryTests(ctx context.Context, project, testIDSubstring string, opts QueryTestsOptions) (testIDs []string, nextPageToken string, err error) {
+	paginationTestID := ""
+	if opts.PageToken != "" {
+		paginationTestID, err = parseQueryTestsPageToken(opts.PageToken)
+		if err != nil {
+			return nil, "", err
+		}
+	}
+	params := map[string]interface{}{
+		"project":       project,
+		"testIdPattern": "%" + spanutil.QuoteLike(testIDSubstring) + "%",
+		"subRealms":     opts.SubRealms,
+
+		// Control pagination.
+		"limit":            opts.PageSize,
+		"paginationTestId": paginationTestID,
+	}
+	input := map[string]interface{}{
+		"hasLimit": opts.PageSize > 0,
+		"params":   params,
+	}
+
+	stmt, err := spanutil.GenerateStatement(QueryTestsQueryTmpl, QueryTestsQueryTmpl.Name(), input)
+	if err != nil {
+		return nil, "", err
+	}
+	stmt.Params = params
+
+	var b spanutil.Buffer
+	testIDs = make([]string, 0, opts.PageSize)
+	err = span.Query(ctx, stmt).Do(func(row *spanner.Row) error {
+		var testID string
+		err := b.FromSpanner(
+			row,
+			&testID,
+		)
+		if err != nil {
+			return err
+		}
+		testIDs = append(testIDs, testID)
+		return nil
+	})
+	if err != nil {
+		return nil, "", err
+	}
+
+	if opts.PageSize != 0 && len(testIDs) == opts.PageSize {
+		lastTestID := testIDs[len(testIDs)-1]
+		nextPageToken = pagination.Token(lastTestID)
+	}
+	return testIDs, nextPageToken, nil
+}
+
+var testHistoryQueryTmpl = template.Must(template.New("").Parse(`
+	{{define "tvStatus"}}
+		CASE
+			WHEN ANY_VALUE(ExonerationReasons IS NOT NULL AND ARRAY_LENGTH(ExonerationReasons) > 0) THEN @exonerated
+			-- Use COALESCE as IsUnexpected uses NULL to indicate false.
+			WHEN LOGICAL_AND(NOT COALESCE(IsUnexpected, FALSE)) THEN @expected
+			WHEN LOGICAL_AND(COALESCE(IsUnexpected, FALSE) AND Status = @skip) THEN @unexpectedlySkipped
+			WHEN LOGICAL_AND(COALESCE(IsUnexpected, FALSE)) THEN @unexpected
+			ELSE @flaky
+		END TvStatus
+	{{end}}
+
+	{{define "testResultFilter"}}
+		Project = @project
+			AND TestId = @testId
+			AND PartitionTime >= @afterTime
+			AND PartitionTime < @beforeTime
+			AND SubRealm IN UNNEST(@subRealms)
+			{{if .hasVariantHash}}
+				AND VariantHash = @variantHash
+			{{end}}
+			{{if .hasVariantKVs}}
+				AND VariantHash IN (
+					SELECT DISTINCT VariantHash
+					FROM TestVariantRealms
+					WHERE
+						Project = @project
+						AND TestId = @testId
+						AND SubRealm IN UNNEST(@subRealms)
+						AND (SELECT LOGICAL_AND(kv IN UNNEST(Variant)) FROM UNNEST(@variantKVs) kv)
+				)
+			{{end}}
+			{{if .hasSubmittedFilter}}
+				AND (ARRAY_LENGTH(ChangelistHosts) > 0) = @hasUnsubmittedChanges
+			{{end}}
+	{{end}}
+
+	{{define "testHistoryQuery"}}
+		SELECT
+			PartitionTime,
+			VariantHash,
+			IngestedInvocationId,
+			{{template "tvStatus" .}},
+			CAST(AVG(IF(Status = @pass, RunDurationUsec, NULL)) AS INT64) AS PassedAvgDurationUsec,
+		FROM TestResults
+		WHERE
+			{{template "testResultFilter" .}}
+			{{if .pagination}}
+				AND	(
+					PartitionTime < TIMESTAMP(@paginationTime)
+						OR (PartitionTime = TIMESTAMP(@paginationTime) AND VariantHash > @paginationVariantHash)
+						OR (PartitionTime = TIMESTAMP(@paginationTime) AND VariantHash = @paginationVariantHash AND IngestedInvocationId > @paginationInvId)
+				)
+			{{end}}
+		GROUP BY PartitionTime, VariantHash, IngestedInvocationId
+		ORDER BY
+			PartitionTime DESC,
+			VariantHash ASC,
+			IngestedInvocationId ASC
+		{{if .hasLimit}}
+			LIMIT @limit
+		{{end}}
+	{{end}}
+
+	{{define "testHistoryStatsQuery"}}
+		WITH verdicts AS (
+			SELECT
+				PartitionTime,
+				VariantHash,
+				IngestedInvocationId,
+				{{template "tvStatus" .}},
+				COUNTIF(Status = @pass AND RunDurationUsec IS NOT NULL) AS PassedWithDurationCount,
+				SUM(IF(Status = @pass, RunDurationUsec, 0)) AS SumPassedDurationUsec,
+			FROM TestResults
+			WHERE
+				{{template "testResultFilter" .}}
+				{{if .pagination}}
+					AND	PartitionTime < TIMESTAMP_ADD(TIMESTAMP(@paginationDate), INTERVAL 1 DAY)
+				{{end}}
+			GROUP BY PartitionTime, VariantHash, IngestedInvocationId
+		)
+
+		SELECT
+			TIMESTAMP_TRUNC(PartitionTime, DAY, "UTC") AS PartitionDate,
+			VariantHash,
+			COUNTIF(TvStatus = @unexpected) AS UnexpectedCount,
+			COUNTIF(TvStatus = @unexpectedlySkipped) AS UnexpectedlySkippedCount,
+			COUNTIF(TvStatus = @flaky) AS FlakyCount,
+			COUNTIF(TvStatus = @exonerated) AS ExoneratedCount,
+			COUNTIF(TvStatus = @expected) AS ExpectedCount,
+			CAST(SAFE_DIVIDE(SUM(SumPassedDurationUsec), SUM(PassedWithDurationCount)) AS INT64) AS PassedAvgDurationUsec,
+		FROM verdicts
+		GROUP BY PartitionDate, VariantHash
+		{{if .pagination}}
+			HAVING
+				PartitionDate < TIMESTAMP(@paginationDate)
+					OR (PartitionDate = TIMESTAMP(@paginationDate) AND VariantHash > @paginationVariantHash)
+		{{end}}
+		ORDER BY
+			PartitionDate DESC,
+			VariantHash ASC
+		{{if .hasLimit}}
+			LIMIT @limit
+		{{end}}
+	{{end}}
+`))
+
+var variantsQueryTmpl = template.Must(template.New("variantsQuery").Parse(`
+	SELECT
+		VariantHash,
+		ANY_VALUE(Variant) as Variant,
+	FROM TestVariantRealms
+	WHERE
+		Project = @project
+			AND TestId = @testId
+			AND SubRealm IN UNNEST(@subRealms)
+			{{if .hasVariantHash}}
+				AND VariantHash = @variantHash
+			{{end}}
+			{{if .hasVariantKVs}}
+				AND (SELECT LOGICAL_AND(kv IN UNNEST(Variant)) FROM UNNEST(@variantKVs) kv)
+			{{end}}
+			AND VariantHash > @paginationVariantHash
+	GROUP BY VariantHash
+	ORDER BY VariantHash ASC
+	{{if .hasLimit}}
+		LIMIT @limit
+	{{end}}
+`))
+
+// The query is written in a way to force spanner NOT to put
+// `SubRealm IN UNNEST(@subRealms)` check in Filter Scan seek condition, which
+// can significantly increase the time it takes to scan the table.
+var QueryTestsQueryTmpl = template.Must(template.New("QueryTestsQuery").Parse(`
+	WITH Tests as (
+		SELECT DISTINCT TestId, SubRealm IN UNNEST(@subRealms) as HasAccess
+		FROM TestRealms
+		WHERE
+			Project = @project
+				AND TestId > @paginationTestId
+				AND TestId LIKE @testIdPattern
+	)
+	SELECT TestId FROM Tests
+	WHERE HasAccess
+	ORDER BY TestId ASC
+	{{if .hasLimit}}
+		LIMIT @limit
+	{{end}}
+`))
diff --git a/analysis/internal/testresults/span_test.go b/analysis/internal/testresults/span_test.go
new file mode 100644
index 0000000..4605cab
--- /dev/null
+++ b/analysis/internal/testresults/span_test.go
@@ -0,0 +1,1058 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/durationpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestReadTestHistory(t *testing.T) {
+	Convey("ReadTestHistory", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		referenceTime := time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+		var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+		var2 := pbutil.Variant("key1", "val2", "key2", "val1")
+		var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+		var4 := pbutil.Variant("key1", "val1", "key2", "val2")
+
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			insertTVR := func(subRealm string, variant *pb.Variant) {
+				span.BufferWrite(ctx, (&TestVariantRealm{
+					Project:     "project",
+					TestID:      "test_id",
+					SubRealm:    subRealm,
+					Variant:     variant,
+					VariantHash: pbutil.VariantHash(variant),
+				}).SaveUnverified())
+			}
+
+			insertTVR("realm", var1)
+			insertTVR("realm", var2)
+			insertTVR("realm", var3)
+			insertTVR("realm2", var4)
+
+			insertTV := func(partitionTime time.Time, variant *pb.Variant, invId string, status pb.TestVerdictStatus, hasUnsubmittedChanges bool, avgDuration *time.Duration) {
+				baseTestResult := NewTestResult().
+					WithProject("project").
+					WithTestID("test_id").
+					WithVariantHash(pbutil.VariantHash(variant)).
+					WithPartitionTime(partitionTime).
+					WithIngestedInvocationID(invId).
+					WithSubRealm("realm").
+					WithStatus(pb.TestResultStatus_PASS)
+				if hasUnsubmittedChanges {
+					baseTestResult = baseTestResult.WithChangelists([]Changelist{
+						{
+							Host:     "mygerrit",
+							Change:   4321,
+							Patchset: 5,
+						},
+						{
+							Host:     "anothergerrit",
+							Change:   5471,
+							Patchset: 6,
+						},
+					})
+				} else {
+					baseTestResult = baseTestResult.WithChangelists(nil)
+				}
+
+				trs := NewTestVerdict().
+					WithBaseTestResult(baseTestResult.Build()).
+					WithStatus(status).
+					WithPassedAvgDuration(avgDuration).
+					Build()
+				for _, tr := range trs {
+					span.BufferWrite(ctx, tr.SaveUnverified())
+				}
+			}
+
+			insertTV(referenceTime.Add(-1*time.Hour), var1, "inv1", pb.TestVerdictStatus_EXPECTED, false, newDuration(11111*time.Microsecond))
+			insertTV(referenceTime.Add(-1*time.Hour), var1, "inv2", pb.TestVerdictStatus_EXONERATED, false, newDuration(1234567890123456*time.Microsecond))
+			insertTV(referenceTime.Add(-1*time.Hour), var2, "inv1", pb.TestVerdictStatus_FLAKY, false, nil)
+
+			insertTV(referenceTime.Add(-2*time.Hour), var1, "inv1", pb.TestVerdictStatus_UNEXPECTED, false, newDuration(33333*time.Microsecond))
+			insertTV(referenceTime.Add(-2*time.Hour), var1, "inv2", pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED, true, nil)
+			insertTV(referenceTime.Add(-2*time.Hour), var2, "inv1", pb.TestVerdictStatus_EXPECTED, true, nil)
+
+			insertTV(referenceTime.Add(-3*time.Hour), var3, "inv1", pb.TestVerdictStatus_EXONERATED, true, newDuration(88888*time.Microsecond))
+
+			return nil
+		})
+		So(err, ShouldBeNil)
+
+		opts := ReadTestHistoryOptions{
+			Project:   "project",
+			TestID:    "test_id",
+			SubRealms: []string{"realm", "realm2"},
+		}
+
+		Convey("pagination works", func() {
+			opts.PageSize = 5
+			verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldNotBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: durationpb.New(11111 * time.Microsecond),
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv2",
+					Status:            pb.TestVerdictStatus_EXONERATED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: durationpb.New(1234567890123456 * time.Microsecond),
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_FLAKY,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_UNEXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: durationpb.New(33333 * time.Microsecond),
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv2",
+					Status:            pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+			})
+
+			opts.PageToken = nextPageToken
+			verdicts, nextPageToken, err = ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var3),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXONERATED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-3 * time.Hour)),
+					PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+				},
+			})
+		})
+
+		Convey("with partition_time_range", func() {
+			opts.TimeRange = &pb.TimeRange{
+				// Inclusive.
+				Earliest: timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+				// Exclusive.
+				Latest: timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+			}
+			verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_UNEXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: durationpb.New(33333 * time.Microsecond),
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv2",
+					Status:            pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+			})
+		})
+
+		Convey("with contains variant_predicate", func() {
+			Convey("with single key-value pair", func() {
+				opts.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key1", "val2"),
+					},
+				}
+				verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+					{
+						TestId:            "test_id",
+						VariantHash:       pbutil.VariantHash(var2),
+						InvocationId:      "inv1",
+						Status:            pb.TestVerdictStatus_FLAKY,
+						PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+						PassedAvgDuration: nil,
+					},
+					{
+						TestId:            "test_id",
+						VariantHash:       pbutil.VariantHash(var2),
+						InvocationId:      "inv1",
+						Status:            pb.TestVerdictStatus_EXPECTED,
+						PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+						PassedAvgDuration: nil,
+					},
+					{
+						TestId:            "test_id",
+						VariantHash:       pbutil.VariantHash(var3),
+						InvocationId:      "inv1",
+						Status:            pb.TestVerdictStatus_EXONERATED,
+						PartitionTime:     timestamppb.New(referenceTime.Add(-3 * time.Hour)),
+						PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+					},
+				})
+			})
+
+			Convey("with multiple key-value pairs", func() {
+				opts.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key1", "val2", "key2", "val2"),
+					},
+				}
+				verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+					{
+						TestId:            "test_id",
+						VariantHash:       pbutil.VariantHash(var3),
+						InvocationId:      "inv1",
+						Status:            pb.TestVerdictStatus_EXONERATED,
+						PartitionTime:     timestamppb.New(referenceTime.Add(-3 * time.Hour)),
+						PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+					},
+				})
+			})
+		})
+
+		Convey("with equals variant_predicate", func() {
+			opts.VariantPredicate = &pb.VariantPredicate{
+				Predicate: &pb.VariantPredicate_Equals{
+					Equals: var2,
+				},
+			}
+			verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_FLAKY,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+			})
+		})
+
+		Convey("with hash_equals variant_predicate", func() {
+			opts.VariantPredicate = &pb.VariantPredicate{
+				Predicate: &pb.VariantPredicate_HashEquals{
+					HashEquals: pbutil.VariantHash(var2),
+				},
+			}
+			verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_FLAKY,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+			})
+		})
+
+		Convey("with submitted_filter", func() {
+			opts.SubmittedFilter = pb.SubmittedFilter_ONLY_UNSUBMITTED
+			verdicts, nextPageToken, err := ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv2",
+					Status:            pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var3),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXONERATED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-3 * time.Hour)),
+					PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+				},
+			})
+
+			opts.SubmittedFilter = pb.SubmittedFilter_ONLY_SUBMITTED
+			verdicts, nextPageToken, err = ReadTestHistory(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.TestVerdict{
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_EXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: durationpb.New(11111 * time.Microsecond),
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv2",
+					Status:            pb.TestVerdictStatus_EXONERATED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: durationpb.New(1234567890123456 * time.Microsecond),
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var2),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_FLAKY,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * time.Hour)),
+					PassedAvgDuration: nil,
+				},
+				{
+					TestId:            "test_id",
+					VariantHash:       pbutil.VariantHash(var1),
+					InvocationId:      "inv1",
+					Status:            pb.TestVerdictStatus_UNEXPECTED,
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					PassedAvgDuration: durationpb.New(33333 * time.Microsecond),
+				},
+			})
+		})
+	})
+}
+
+func newDuration(value time.Duration) *time.Duration {
+	d := new(time.Duration)
+	*d = value
+	return d
+}
+
+func TestReadTestHistoryStats(t *testing.T) {
+	Convey("ReadTestHistoryStats", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		referenceTime := time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)
+
+		day := 24 * time.Hour
+
+		var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+		var2 := pbutil.Variant("key1", "val2", "key2", "val1")
+		var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+		var4 := pbutil.Variant("key1", "val1", "key2", "val2")
+
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			insertTVR := func(subRealm string, variant *pb.Variant) {
+				span.BufferWrite(ctx, (&TestVariantRealm{
+					Project:     "project",
+					TestID:      "test_id",
+					SubRealm:    subRealm,
+					Variant:     variant,
+					VariantHash: pbutil.VariantHash(variant),
+				}).SaveUnverified())
+			}
+
+			insertTVR("realm", var1)
+			insertTVR("realm", var2)
+			insertTVR("realm", var3)
+			insertTVR("realm2", var4)
+
+			insertTV := func(partitionTime time.Time, variant *pb.Variant, invId string, status pb.TestVerdictStatus, hasUnsubmittedChanges bool, avgDuration *time.Duration) {
+				baseTestResult := NewTestResult().
+					WithProject("project").
+					WithTestID("test_id").
+					WithVariantHash(pbutil.VariantHash(variant)).
+					WithPartitionTime(partitionTime).
+					WithIngestedInvocationID(invId).
+					WithSubRealm("realm").
+					WithStatus(pb.TestResultStatus_PASS)
+				if hasUnsubmittedChanges {
+					baseTestResult = baseTestResult.WithChangelists([]Changelist{
+						{
+							Host:     "mygerrit",
+							Change:   4321,
+							Patchset: 5,
+						},
+					})
+				} else {
+					baseTestResult = baseTestResult.WithChangelists(nil)
+				}
+
+				trs := NewTestVerdict().
+					WithBaseTestResult(baseTestResult.Build()).
+					WithStatus(status).
+					WithPassedAvgDuration(avgDuration).
+					Build()
+				for _, tr := range trs {
+					span.BufferWrite(ctx, tr.SaveUnverified())
+				}
+			}
+
+			insertTV(referenceTime.Add(-1*time.Hour), var1, "inv1", pb.TestVerdictStatus_EXPECTED, false, newDuration(22222*time.Microsecond))
+			insertTV(referenceTime.Add(-12*time.Hour), var1, "inv2", pb.TestVerdictStatus_EXONERATED, false, newDuration(1234567890123456*time.Microsecond))
+			insertTV(referenceTime.Add(-24*time.Hour), var2, "inv1", pb.TestVerdictStatus_FLAKY, false, nil)
+
+			insertTV(referenceTime.Add(-day-1*time.Hour), var1, "inv1", pb.TestVerdictStatus_UNEXPECTED, false, newDuration(33333*time.Microsecond))
+			insertTV(referenceTime.Add(-day-12*time.Hour), var1, "inv2", pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED, true, nil)
+			insertTV(referenceTime.Add(-day-24*time.Hour), var2, "inv1", pb.TestVerdictStatus_EXPECTED, true, nil)
+
+			insertTV(referenceTime.Add(-2*day-3*time.Hour), var3, "inv1", pb.TestVerdictStatus_EXONERATED, true, newDuration(88888*time.Microsecond))
+			return nil
+		})
+		So(err, ShouldBeNil)
+
+		opts := ReadTestHistoryOptions{
+			Project:   "project",
+			TestID:    "test_id",
+			SubRealms: []string{"realm", "realm2"},
+		}
+
+		Convey("pagination works", func() {
+			opts.PageSize = 3
+			verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldNotBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+					VariantHash:       pbutil.VariantHash(var1),
+					ExpectedCount:     1,
+					ExoneratedCount:   1,
+					PassedAvgDuration: durationpb.New(((22222 + 1234567890123456) / 2) * time.Microsecond),
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					FlakyCount:        1,
+					PassedAvgDuration: nil,
+				},
+				{
+					PartitionTime:            timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:              pbutil.VariantHash(var1),
+					UnexpectedCount:          1,
+					UnexpectedlySkippedCount: 1,
+					PassedAvgDuration:        durationpb.New(33333 * time.Microsecond),
+				},
+			})
+
+			opts.PageToken = nextPageToken
+			verdicts, nextPageToken, err = ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					ExpectedCount:     1,
+					PassedAvgDuration: nil,
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-3 * day)),
+					VariantHash:       pbutil.VariantHash(var3),
+					ExoneratedCount:   1,
+					PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+				},
+			})
+		})
+
+		Convey("with partition_time_range", func() {
+			Convey("day boundaries", func() {
+				opts.TimeRange = &pb.TimeRange{
+					// Inclusive.
+					Earliest: timestamppb.New(referenceTime.Add(-2 * day)),
+					// Exclusive.
+					Latest: timestamppb.New(referenceTime.Add(-1 * day)),
+				}
+				verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+					{
+						PartitionTime:            timestamppb.New(referenceTime.Add(-2 * day)),
+						VariantHash:              pbutil.VariantHash(var1),
+						UnexpectedCount:          1,
+						UnexpectedlySkippedCount: 1,
+						PassedAvgDuration:        durationpb.New(33333 * time.Microsecond),
+					},
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+						VariantHash:       pbutil.VariantHash(var2),
+						ExpectedCount:     1,
+						PassedAvgDuration: nil,
+					},
+				})
+			})
+			Convey("part-day boundaries", func() {
+				opts.TimeRange = &pb.TimeRange{
+					// Inclusive.
+					Earliest: timestamppb.New(referenceTime.Add(-2*day - 3*time.Hour)),
+					// Exclusive.
+					Latest: timestamppb.New(referenceTime.Add(-1*day - 1*time.Hour)),
+				}
+				verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+					{
+						PartitionTime:            timestamppb.New(referenceTime.Add(-2 * day)),
+						VariantHash:              pbutil.VariantHash(var1),
+						UnexpectedlySkippedCount: 1,
+						PassedAvgDuration:        nil,
+					},
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+						VariantHash:       pbutil.VariantHash(var2),
+						ExpectedCount:     1,
+						PassedAvgDuration: nil,
+					},
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-3 * day)),
+						VariantHash:       pbutil.VariantHash(var3),
+						ExoneratedCount:   1,
+						PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+					},
+				})
+			})
+		})
+
+		Convey("with contains variant_predicate", func() {
+			Convey("with single key-value pair", func() {
+				opts.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key1", "val2"),
+					},
+				}
+				verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+						VariantHash:       pbutil.VariantHash(var2),
+						FlakyCount:        1,
+						PassedAvgDuration: nil,
+					},
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+						VariantHash:       pbutil.VariantHash(var2),
+						ExpectedCount:     1,
+						PassedAvgDuration: nil,
+					},
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-3 * day)),
+						VariantHash:       pbutil.VariantHash(var3),
+						ExoneratedCount:   1,
+						PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+					},
+				})
+			})
+
+			Convey("with multiple key-value pairs", func() {
+				opts.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key1", "val2", "key2", "val2"),
+					},
+				}
+				verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+					{
+						PartitionTime:     timestamppb.New(referenceTime.Add(-3 * day)),
+						VariantHash:       pbutil.VariantHash(var3),
+						ExoneratedCount:   1,
+						PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+					},
+				})
+			})
+		})
+
+		Convey("with equals variant_predicate", func() {
+			opts.VariantPredicate = &pb.VariantPredicate{
+				Predicate: &pb.VariantPredicate_Equals{
+					Equals: var2,
+				},
+			}
+			verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					FlakyCount:        1,
+					PassedAvgDuration: nil,
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					ExpectedCount:     1,
+					PassedAvgDuration: nil,
+				},
+			})
+		})
+
+		Convey("with hash_equals variant_predicate", func() {
+			opts.VariantPredicate = &pb.VariantPredicate{
+				Predicate: &pb.VariantPredicate_HashEquals{
+					HashEquals: pbutil.VariantHash(var2),
+				},
+			}
+			verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					FlakyCount:        1,
+					PassedAvgDuration: nil,
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					ExpectedCount:     1,
+					PassedAvgDuration: nil,
+				},
+			})
+		})
+
+		Convey("with empty hash_equals variant_predicate", func() {
+			opts.VariantPredicate = &pb.VariantPredicate{
+				Predicate: &pb.VariantPredicate_HashEquals{
+					HashEquals: "",
+				},
+			}
+			verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldBeEmpty)
+		})
+
+		Convey("with submitted_filter", func() {
+			opts.SubmittedFilter = pb.SubmittedFilter_ONLY_UNSUBMITTED
+			verdicts, nextPageToken, err := ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+				{
+					PartitionTime:            timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:              pbutil.VariantHash(var1),
+					UnexpectedlySkippedCount: 1,
+					PassedAvgDuration:        nil,
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					ExpectedCount:     1,
+					PassedAvgDuration: nil,
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-3 * day)),
+					VariantHash:       pbutil.VariantHash(var3),
+					ExoneratedCount:   1,
+					PassedAvgDuration: durationpb.New(88888 * time.Microsecond),
+				},
+			})
+
+			opts.SubmittedFilter = pb.SubmittedFilter_ONLY_SUBMITTED
+			verdicts, nextPageToken, err = ReadTestHistoryStats(span.Single(ctx), opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(verdicts, ShouldResembleProto, []*pb.QueryTestHistoryStatsResponse_Group{
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+					VariantHash:       pbutil.VariantHash(var1),
+					ExpectedCount:     1,
+					ExoneratedCount:   1,
+					PassedAvgDuration: durationpb.New(((22222 + 1234567890123456) / 2) * time.Microsecond),
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+					VariantHash:       pbutil.VariantHash(var2),
+					FlakyCount:        1,
+					PassedAvgDuration: nil,
+				},
+				{
+					PartitionTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+					VariantHash:       pbutil.VariantHash(var1),
+					UnexpectedCount:   1,
+					PassedAvgDuration: durationpb.New(33333 * time.Microsecond),
+				},
+			})
+		})
+	})
+}
+
+func TestReadVariants(t *testing.T) {
+	Convey("ReadVariants", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+		var2 := pbutil.Variant("key1", "val2", "key2", "val1")
+		var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+		var4 := pbutil.Variant("key1", "val1", "key2", "val2")
+
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			insertTVR := func(subRealm string, variant *pb.Variant) {
+				span.BufferWrite(ctx, (&TestVariantRealm{
+					Project:     "project",
+					TestID:      "test_id",
+					SubRealm:    subRealm,
+					Variant:     variant,
+					VariantHash: pbutil.VariantHash(variant),
+				}).SaveUnverified())
+			}
+
+			insertTVR("realm1", var1)
+			insertTVR("realm1", var2)
+
+			insertTVR("realm2", var2)
+			insertTVR("realm2", var3)
+
+			insertTVR("realm3", var4)
+
+			return nil
+		})
+		So(err, ShouldBeNil)
+
+		Convey("pagination works", func() {
+			opts := ReadVariantsOptions{PageSize: 3, SubRealms: []string{"realm1", "realm2", "realm3"}}
+			variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldNotBeEmpty)
+			So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+				{
+					VariantHash: pbutil.VariantHash(var1),
+					Variant:     var1,
+				},
+				{
+					VariantHash: pbutil.VariantHash(var3),
+					Variant:     var3,
+				},
+				{
+					VariantHash: pbutil.VariantHash(var4),
+					Variant:     var4,
+				},
+			})
+
+			opts.PageToken = nextPageToken
+			variants, nextPageToken, err = ReadVariants(span.Single(ctx), "project", "test_id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+				{
+					VariantHash: pbutil.VariantHash(var2),
+					Variant:     var2,
+				},
+			})
+		})
+
+		Convey("multi-realm works", func() {
+			opts := ReadVariantsOptions{SubRealms: []string{"realm1", "realm2"}}
+			variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+				{
+					VariantHash: pbutil.VariantHash(var1),
+					Variant:     var1,
+				},
+				{
+					VariantHash: pbutil.VariantHash(var3),
+					Variant:     var3,
+				},
+				{
+					VariantHash: pbutil.VariantHash(var2),
+					Variant:     var2,
+				},
+			})
+		})
+
+		Convey("single-realm works", func() {
+			opts := ReadVariantsOptions{SubRealms: []string{"realm2"}}
+			variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+				{
+					VariantHash: pbutil.VariantHash(var3),
+					Variant:     var3,
+				},
+				{
+					VariantHash: pbutil.VariantHash(var2),
+					Variant:     var2,
+				},
+			})
+		})
+
+		Convey("with contains variant predicate", func() {
+			Convey("with single key-value pair", func() {
+				opts := ReadVariantsOptions{
+					SubRealms: []string{"realm1", "realm2"},
+					VariantPredicate: &pb.VariantPredicate{
+						Predicate: &pb.VariantPredicate_Contains{
+							Contains: pbutil.Variant("key1", "val2"),
+						},
+					},
+				}
+				variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+					{
+						VariantHash: pbutil.VariantHash(var3),
+						Variant:     var3,
+					},
+					{
+						VariantHash: pbutil.VariantHash(var2),
+						Variant:     var2,
+					},
+				})
+			})
+
+			Convey("with multiple key-value pairs", func() {
+				opts := ReadVariantsOptions{
+					SubRealms: []string{"realm1", "realm2"},
+					VariantPredicate: &pb.VariantPredicate{
+						Predicate: &pb.VariantPredicate_Contains{
+							Contains: pbutil.Variant("key1", "val2", "key2", "val2"),
+						},
+					},
+				}
+				variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+				So(err, ShouldBeNil)
+				So(nextPageToken, ShouldBeEmpty)
+				So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+					{
+						VariantHash: pbutil.VariantHash(var3),
+						Variant:     var3,
+					},
+				})
+			})
+		})
+
+		Convey("with equals variant predicate", func() {
+			opts := ReadVariantsOptions{
+				SubRealms: []string{"realm1", "realm2"},
+				VariantPredicate: &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Equals{
+						Equals: var2,
+					},
+				},
+			}
+			variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+				{
+					VariantHash: pbutil.VariantHash(var2),
+					Variant:     var2,
+				},
+			})
+		})
+
+		Convey("with hash_equals variant predicate", func() {
+			opts := ReadVariantsOptions{
+				SubRealms: []string{"realm2"},
+				VariantPredicate: &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_HashEquals{
+						HashEquals: pbutil.VariantHash(var2),
+					},
+				},
+			}
+			variants, nextPageToken, err := ReadVariants(span.Single(ctx), "project", "test_id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(variants, ShouldResembleProto, []*pb.QueryVariantsResponse_VariantInfo{
+				{
+					VariantHash: pbutil.VariantHash(var2),
+					Variant:     var2,
+				},
+			})
+		})
+	})
+}
+
+func TestQueryTests(t *testing.T) {
+	Convey("QueryTests", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			insertTest := func(subRealm string, testID string) {
+				span.BufferWrite(ctx, (&TestRealm{
+					Project:  "project",
+					TestID:   testID,
+					SubRealm: subRealm,
+				}).SaveUnverified())
+			}
+
+			insertTest("realm1", "test-id00")
+			insertTest("realm2", "test-id01")
+			insertTest("realm3", "test-id02")
+
+			insertTest("realm1", "test-id10")
+			insertTest("realm2", "test-id11")
+			insertTest("realm3", "test-id12")
+
+			insertTest("realm1", "test-id20")
+			insertTest("realm2", "test-id21")
+			insertTest("realm3", "test-id22")
+
+			insertTest("realm1", "special%_characters")
+			insertTest("realm1", "specialxxcharacters")
+
+			return nil
+		})
+		So(err, ShouldBeNil)
+
+		Convey("pagination works", func() {
+			opts := QueryTestsOptions{PageSize: 2, SubRealms: []string{"realm1", "realm2", "realm3"}}
+			testIDs, nextPageToken, err := QueryTests(span.Single(ctx), "project", "id1", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldNotBeEmpty)
+			So(testIDs, ShouldResemble, []string{
+				"test-id10",
+				"test-id11",
+			})
+
+			opts.PageToken = nextPageToken
+			testIDs, nextPageToken, err = QueryTests(span.Single(ctx), "project", "id1", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(testIDs, ShouldResemble, []string{
+				"test-id12",
+			})
+		})
+
+		Convey("multi-realm works", func() {
+			opts := QueryTestsOptions{SubRealms: []string{"realm1", "realm2"}}
+			testIDs, nextPageToken, err := QueryTests(span.Single(ctx), "project", "test-id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(testIDs, ShouldResemble, []string{
+				"test-id00",
+				"test-id01",
+				"test-id10",
+				"test-id11",
+				"test-id20",
+				"test-id21",
+			})
+		})
+
+		Convey("single-realm works", func() {
+			opts := QueryTestsOptions{SubRealms: []string{"realm3"}}
+			testIDs, nextPageToken, err := QueryTests(span.Single(ctx), "project", "test-id", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(testIDs, ShouldResemble, []string{
+				"test-id02",
+				"test-id12",
+				"test-id22",
+			})
+		})
+
+		Convey("special character works", func() {
+			opts := QueryTestsOptions{SubRealms: []string{"realm1", "realm2", "realm3"}}
+			testIDs, nextPageToken, err := QueryTests(span.Single(ctx), "project", "special%_characters", opts)
+			So(err, ShouldBeNil)
+			So(nextPageToken, ShouldBeEmpty)
+			So(testIDs, ShouldResemble, []string{
+				"special%_characters",
+			})
+		})
+	})
+}
diff --git a/analysis/internal/testresults/test_data.go b/analysis/internal/testresults/test_data.go
new file mode 100644
index 0000000..d853048
--- /dev/null
+++ b/analysis/internal/testresults/test_data.go
@@ -0,0 +1,322 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"context"
+	"time"
+
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// June 17th, 2022 is a Friday. The preceding 5 * 24 weekday hour
+// are as follows:
+//             Inclusive              - Exclusive
+// Interval 0: (-1 day) Thursday 8am  - (now)    Friday 8am
+// Interval 1: (-2 day) Wednesday 8am - (-1 day) Thursday 8am
+// Interval 2: (-3 day) Tuesday 8am   - (-2 day) Wednesday 8am
+// Interval 3: (-4 day) Monday 8am    - (-3 day) Tuesday 8am
+// Interval 4: (-7 day) Friday 8am    - (-4 day) Monday 8am
+var referenceTime = time.Date(2022, time.June, 17, 8, 0, 0, 0, time.UTC)
+
+// CreateQueryFailureRateTestData creates test data in Spanner for testing
+// QueryFailureRate.
+func CreateQueryFailureRateTestData(ctx context.Context) error {
+	var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+	var2 := pbutil.Variant("key1", "val2", "key2", "val1")
+	var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+
+	_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		insertTV := func(partitionTime time.Time, variant *pb.Variant, invId string, runStatuses []RunStatus, run *PresubmitRun, changeListNumber ...int64) {
+			baseTestResult := NewTestResult().
+				WithProject("project").
+				WithTestID("test_id").
+				WithVariantHash(pbutil.VariantHash(variant)).
+				WithPartitionTime(partitionTime).
+				WithIngestedInvocationID(invId).
+				WithSubRealm("realm").
+				WithStatus(pb.TestResultStatus_PASS).
+				WithPresubmitRun(run)
+
+			var changelists []Changelist
+			for _, clNum := range changeListNumber {
+				changelists = append(changelists, Changelist{
+					Host:     "mygerrit",
+					Change:   clNum,
+					Patchset: 5,
+				})
+			}
+			baseTestResult = baseTestResult.WithChangelists(changelists)
+
+			trs := NewTestVerdict().
+				WithBaseTestResult(baseTestResult.Build()).
+				WithRunStatus(runStatuses...).
+				Build()
+			for _, tr := range trs {
+				span.BufferWrite(ctx, tr.SaveUnverified())
+			}
+		}
+
+		// pass, fail is shorthand here for expected and unexpected run,
+		// where for the purposes of this RPC, a flaky run counts as
+		// "expected" (as it has at least one expected result).
+		passFail := []RunStatus{Flaky, Unexpected}
+		failPass := []RunStatus{Unexpected, Flaky}
+		pass := []RunStatus{Flaky}
+		fail := []RunStatus{Unexpected}
+		failFail := []RunStatus{Unexpected, Unexpected}
+
+		day := 24 * time.Hour
+
+		userRun := &PresubmitRun{Owner: "user"}
+		automationRun := &PresubmitRun{Owner: "automation"}
+
+		insertTV(referenceTime.Add(-6*day), var1, "inv1", failPass, userRun, 10)
+		// duplicate-cl result should not be used, inv3 result should be
+		// used instead (as only one verdict per changelist is used, and
+		// inv3 is more recent).
+		insertTV(referenceTime.Add(-4*day), var1, "duplicate-cl", failPass, userRun, 1)
+		// duplicate-cl2 result should not be used, inv3 result should be used instead
+		// (as only one verdict per changelist is used, and inv3 is flaky
+		// and this is not).
+		insertTV(referenceTime.Add(-1*time.Hour), var1, "duplicate-cl2", pass, userRun, 1)
+
+		insertTV(referenceTime.Add(-4*day), var1, "inv2", pass, nil, 2)
+		insertTV(referenceTime.Add(-2*time.Hour), var1, "inv3", failPass, userRun, 1)
+
+		insertTV(referenceTime.Add(-3*day), var1, "inv4", failPass, automationRun)
+		insertTV(referenceTime.Add(-3*day), var1, "inv5", passFail, nil, 3)
+		insertTV(referenceTime.Add(-2*day), var1, "inv6", fail, userRun, 4)
+		insertTV(referenceTime.Add(-3*day), var1, "inv7", failFail, nil)
+		// should not be used, as tests multiple CLs, and too hard
+		// to deduplicate the verdicts.
+		insertTV(referenceTime.Add(-2*day), var1, "many-cl", failPass, userRun, 1, 3)
+
+		// should not be used, as times  fall outside the queried intervals.
+		insertTV(referenceTime.Add(-7*day-time.Microsecond), var1, "too-early", failPass, userRun, 5)
+		insertTV(referenceTime, var1, "too-late", failPass, userRun, 6)
+
+		insertTV(referenceTime.Add(-4*day), var2, "inv1", failPass, userRun, 1)
+		insertTV(referenceTime.Add(-3*day), var2, "inv2", failPass, userRun, 2)
+
+		insertTV(referenceTime.Add(-5*day), var3, "duplicate-cl1", passFail, userRun, 1)
+		insertTV(referenceTime.Add(-3*day), var3, "duplicate-cl2", failPass, userRun, 1)
+		insertTV(referenceTime.Add(-1*day), var3, "inv8", failPass, userRun, 1)
+
+		return nil
+	})
+	return err
+}
+
+func QueryFailureRateSampleRequest() (project string, asAtTime time.Time, testVariants []*pb.TestVariantIdentifier) {
+	var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+	var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+	testVariants = []*pb.TestVariantIdentifier{
+		{
+			TestId:  "test_id",
+			Variant: var1,
+		},
+		{
+			TestId:  "test_id",
+			Variant: var3,
+		},
+	}
+	asAtTime = time.Date(2022, time.June, 17, 8, 0, 0, 0, time.UTC)
+	return "project", asAtTime, testVariants
+}
+
+// QueryFailureRateSampleResponse returns expected response data from QueryFailureRate
+// after being invoked with QueryFailureRateSampleRequest.
+// It is assumed test data was setup with CreateQueryFailureRateTestData.
+func QueryFailureRateSampleResponse() *pb.QueryTestVariantFailureRateResponse {
+	var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+	var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+
+	day := 24 * time.Hour
+
+	intervals := []*pb.QueryTestVariantFailureRateResponse_Interval{
+		{
+			IntervalAge: 1,
+			StartTime:   timestamppb.New(referenceTime.Add(-1 * day)),
+			EndTime:     timestamppb.New(referenceTime),
+		},
+		{
+			IntervalAge: 2,
+			StartTime:   timestamppb.New(referenceTime.Add(-2 * day)),
+			EndTime:     timestamppb.New(referenceTime.Add(-1 * day)),
+		},
+		{
+			IntervalAge: 3,
+			StartTime:   timestamppb.New(referenceTime.Add(-3 * day)),
+			EndTime:     timestamppb.New(referenceTime.Add(-2 * day)),
+		},
+		{
+			IntervalAge: 4,
+			StartTime:   timestamppb.New(referenceTime.Add(-4 * day)),
+			EndTime:     timestamppb.New(referenceTime.Add(-3 * day)),
+		},
+		{
+			IntervalAge: 5,
+			StartTime:   timestamppb.New(referenceTime.Add(-7 * day)),
+			EndTime:     timestamppb.New(referenceTime.Add(-4 * day)),
+		},
+	}
+
+	analysis := []*pb.TestVariantFailureRateAnalysis{
+		{
+			TestId:  "test_id",
+			Variant: var1,
+			IntervalStats: []*pb.TestVariantFailureRateAnalysis_IntervalStats{
+				{
+					IntervalAge:           1,
+					TotalRunFlakyVerdicts: 1, // inv3.
+				},
+				{
+					IntervalAge:                2,
+					TotalRunUnexpectedVerdicts: 1, // inv6.
+				},
+				{
+					IntervalAge:                3,
+					TotalRunFlakyVerdicts:      2, // inv4, inv5.
+					TotalRunUnexpectedVerdicts: 1, // inv7.
+				},
+				{
+					IntervalAge:              4,
+					TotalRunExpectedVerdicts: 1, // inv2.
+				},
+				{
+					IntervalAge:           5,
+					TotalRunFlakyVerdicts: 1, //inv1.
+
+				},
+			},
+			RunFlakyVerdictExamples: []*pb.TestVariantFailureRateAnalysis_VerdictExample{
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					IngestedInvocationId: "inv3",
+					Changelists:          expectedPBChangelist(1),
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-3 * day)),
+					IngestedInvocationId: "inv4",
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-3 * day)),
+					IngestedInvocationId: "inv5",
+					Changelists:          expectedPBChangelist(3),
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-6 * day)),
+					IngestedInvocationId: "inv1",
+					Changelists:          expectedPBChangelist(10),
+				},
+			},
+			// inv4 should not be included as it is a CL authored by automation.
+			RecentVerdicts: []*pb.TestVariantFailureRateAnalysis_RecentVerdict{
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-2 * time.Hour)),
+					IngestedInvocationId: "inv3",
+					Changelists:          expectedPBChangelist(1),
+					HasUnexpectedRuns:    true,
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-2 * day)),
+					IngestedInvocationId: "inv6",
+					Changelists:          expectedPBChangelist(4),
+					HasUnexpectedRuns:    true,
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-3 * day)),
+					IngestedInvocationId: "inv5",
+					Changelists:          expectedPBChangelist(3),
+					HasUnexpectedRuns:    true,
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-3 * day)),
+					IngestedInvocationId: "inv7",
+					HasUnexpectedRuns:    true,
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-4 * day)),
+					IngestedInvocationId: "inv2",
+					Changelists:          expectedPBChangelist(2),
+					HasUnexpectedRuns:    false,
+				},
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-6 * day)),
+					IngestedInvocationId: "inv1",
+					Changelists:          expectedPBChangelist(10),
+					HasUnexpectedRuns:    true,
+				},
+			},
+		},
+		{
+			TestId:  "test_id",
+			Variant: var3,
+			IntervalStats: []*pb.TestVariantFailureRateAnalysis_IntervalStats{
+				{
+					IntervalAge:           1,
+					TotalRunFlakyVerdicts: 1, // inv8.
+				},
+				{
+					IntervalAge: 2,
+				},
+				{
+					IntervalAge: 3,
+				},
+				{
+					IntervalAge: 4,
+				},
+				{
+					IntervalAge: 5,
+				},
+			},
+			RunFlakyVerdictExamples: []*pb.TestVariantFailureRateAnalysis_VerdictExample{
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-1 * day)),
+					IngestedInvocationId: "inv8",
+					Changelists:          expectedPBChangelist(1),
+				},
+			},
+			RecentVerdicts: []*pb.TestVariantFailureRateAnalysis_RecentVerdict{
+				{
+					PartitionTime:        timestamppb.New(referenceTime.Add(-1 * day)),
+					IngestedInvocationId: "inv8",
+					Changelists:          expectedPBChangelist(1),
+					HasUnexpectedRuns:    true,
+				},
+			},
+		},
+	}
+
+	return &pb.QueryTestVariantFailureRateResponse{
+		Intervals:    intervals,
+		TestVariants: analysis,
+	}
+}
+
+func expectedPBChangelist(change int64) []*pb.Changelist {
+	return []*pb.Changelist{
+		{
+			Host:     "mygerrit-review.googlesource.com",
+			Change:   change,
+			Patchset: 5,
+		},
+	}
+}
diff --git a/analysis/internal/testresults/test_utils.go b/analysis/internal/testresults/test_utils.go
new file mode 100644
index 0000000..eb88734
--- /dev/null
+++ b/analysis/internal/testresults/test_utils.go
@@ -0,0 +1,380 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testresults
+
+import (
+	"time"
+
+	"go.chromium.org/luci/analysis/internal/testresults/gitreferences"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// TestResultBuilder provides methods to build a test result for testing.
+type TestResultBuilder struct {
+	result TestResult
+}
+
+func NewTestResult() *TestResultBuilder {
+	d := time.Hour
+	result := TestResult{
+		Project:              "proj",
+		TestID:               "test_id",
+		PartitionTime:        time.Date(2020, 1, 2, 3, 4, 5, 6, time.UTC),
+		VariantHash:          "hash",
+		IngestedInvocationID: "inv-id",
+		RunIndex:             2,
+		ResultIndex:          3,
+		IsUnexpected:         true,
+		RunDuration:          &d,
+		Status:               pb.TestResultStatus_PASS,
+		ExonerationReasons:   nil,
+		SubRealm:             "realm",
+		BuildStatus:          pb.BuildStatus_BUILD_STATUS_SUCCESS,
+		GitReferenceHash:     gitreferences.GitReferenceHash("hostname", "repository", "reference"),
+		CommitPosition:       1893189,
+		Changelists: []Changelist{
+			{
+				Host:     "mygerrit",
+				Change:   12345678,
+				Patchset: 9,
+			},
+			{
+				Host:     "anothergerrit",
+				Change:   234568790,
+				Patchset: 1,
+			},
+		},
+	}
+	return &TestResultBuilder{
+		result: result,
+	}
+}
+
+func (b *TestResultBuilder) WithProject(project string) *TestResultBuilder {
+	b.result.Project = project
+	return b
+}
+
+func (b *TestResultBuilder) WithTestID(testID string) *TestResultBuilder {
+	b.result.TestID = testID
+	return b
+}
+
+func (b *TestResultBuilder) WithPartitionTime(partitionTime time.Time) *TestResultBuilder {
+	b.result.PartitionTime = partitionTime
+	return b
+}
+
+func (b *TestResultBuilder) WithVariantHash(variantHash string) *TestResultBuilder {
+	b.result.VariantHash = variantHash
+	return b
+}
+
+func (b *TestResultBuilder) WithIngestedInvocationID(invID string) *TestResultBuilder {
+	b.result.IngestedInvocationID = invID
+	return b
+}
+
+func (b *TestResultBuilder) WithRunIndex(runIndex int64) *TestResultBuilder {
+	b.result.RunIndex = runIndex
+	return b
+}
+
+func (b *TestResultBuilder) WithResultIndex(resultIndex int64) *TestResultBuilder {
+	b.result.ResultIndex = resultIndex
+	return b
+}
+
+func (b *TestResultBuilder) WithIsUnexpected(unexpected bool) *TestResultBuilder {
+	b.result.IsUnexpected = unexpected
+	return b
+}
+
+func (b *TestResultBuilder) WithRunDuration(duration time.Duration) *TestResultBuilder {
+	b.result.RunDuration = &duration
+	return b
+}
+
+func (b *TestResultBuilder) WithoutRunDuration() *TestResultBuilder {
+	b.result.RunDuration = nil
+	return b
+}
+
+func (b *TestResultBuilder) WithStatus(status pb.TestResultStatus) *TestResultBuilder {
+	b.result.Status = status
+	return b
+}
+
+func (b *TestResultBuilder) WithExonerationReasons(exonerationReasons ...pb.ExonerationReason) *TestResultBuilder {
+	b.result.ExonerationReasons = exonerationReasons
+	return b
+}
+
+func (b *TestResultBuilder) WithoutExoneration() *TestResultBuilder {
+	b.result.ExonerationReasons = nil
+	return b
+}
+
+func (b *TestResultBuilder) WithSubRealm(subRealm string) *TestResultBuilder {
+	b.result.SubRealm = subRealm
+	return b
+}
+
+func (b *TestResultBuilder) WithBuildStatus(buildStatus pb.BuildStatus) *TestResultBuilder {
+	b.result.BuildStatus = buildStatus
+	return b
+}
+
+func (b *TestResultBuilder) WithCommitPosition(gitReferenceHash []byte, commitPosition int64) *TestResultBuilder {
+	b.result.GitReferenceHash = gitReferenceHash
+	b.result.CommitPosition = commitPosition
+	return b
+}
+
+func (b *TestResultBuilder) WithoutCommitPosition() *TestResultBuilder {
+	b.result.GitReferenceHash = nil
+	b.result.CommitPosition = 0
+	return b
+}
+
+func (b *TestResultBuilder) WithPresubmitRun(run *PresubmitRun) *TestResultBuilder {
+	if run != nil {
+		// Copy run to stop changes the caller may make to run
+		// after this call propagating into the resultant test result.
+		r := new(PresubmitRun)
+		*r = *run
+		run = r
+	}
+	b.result.PresubmitRun = run
+	return b
+}
+
+func (b *TestResultBuilder) WithChangelists(changelist []Changelist) *TestResultBuilder {
+	// Copy changelist to stop changes the caller may make to changelist
+	// after this call propagating into the resultant test result.
+	cls := make([]Changelist, len(changelist))
+	copy(cls, changelist)
+
+	// Ensure changelists are stored sorted.
+	SortChangelists(cls)
+	b.result.Changelists = cls
+	return b
+}
+
+func (b *TestResultBuilder) Build() *TestResult {
+	// Copy the result, so that calling further methods on the builder does
+	// not change the returned test verdict.
+	result := new(TestResult)
+	*result = b.result
+
+	if b.result.PresubmitRun != nil {
+		run := new(PresubmitRun)
+		*run = *b.result.PresubmitRun
+		result.PresubmitRun = run
+	}
+	cls := make([]Changelist, len(b.result.Changelists))
+	copy(cls, b.result.Changelists)
+	result.Changelists = cls
+	return result
+}
+
+// TestVerdictBuilder provides methods to build a test variant for testing.
+type TestVerdictBuilder struct {
+	baseResult        TestResult
+	status            *pb.TestVerdictStatus
+	runStatuses       []RunStatus
+	passedAvgDuration *time.Duration
+}
+
+type RunStatus int64
+
+const (
+	Unexpected RunStatus = iota
+	Flaky
+	Expected
+)
+
+func NewTestVerdict() *TestVerdictBuilder {
+	result := new(TestVerdictBuilder)
+	result.baseResult = *NewTestResult().WithStatus(pb.TestResultStatus_PASS).Build()
+	status := pb.TestVerdictStatus_FLAKY
+	result.status = &status
+	result.runStatuses = nil
+	d := 919191 * time.Microsecond
+	result.passedAvgDuration = &d
+	return result
+}
+
+// WithBaseTestResult specifies a test result to use as the template for
+// the test variant's test results.
+func (b *TestVerdictBuilder) WithBaseTestResult(testResult *TestResult) *TestVerdictBuilder {
+	b.baseResult = *testResult
+	return b
+}
+
+// WithPassedAvgDuration specifies the average duration to use for
+// passed test results. If setting to a non-nil value, make sure
+// to set the result status as passed on the base test result if
+// using this option.
+func (b *TestVerdictBuilder) WithPassedAvgDuration(duration *time.Duration) *TestVerdictBuilder {
+	b.passedAvgDuration = duration
+	return b
+}
+
+// WithStatus specifies the status of the test verdict.
+func (b *TestVerdictBuilder) WithStatus(status pb.TestVerdictStatus) *TestVerdictBuilder {
+	b.status = &status
+	return b
+}
+
+// WithRunStatus specifies the status of runs of the test verdict.
+func (b *TestVerdictBuilder) WithRunStatus(runStatuses ...RunStatus) *TestVerdictBuilder {
+	b.runStatuses = runStatuses
+	return b
+}
+
+func applyStatus(trs []*TestResult, status pb.TestVerdictStatus) {
+	// Set all test results to unexpected, not exonerated by default.
+	for _, tr := range trs {
+		tr.IsUnexpected = true
+		tr.ExonerationReasons = nil
+	}
+	switch status {
+	case pb.TestVerdictStatus_EXONERATED:
+		for _, tr := range trs {
+			tr.ExonerationReasons = []pb.ExonerationReason{pb.ExonerationReason_OCCURS_ON_MAINLINE}
+		}
+	case pb.TestVerdictStatus_UNEXPECTED:
+		// No changes required.
+	case pb.TestVerdictStatus_EXPECTED:
+		allSkipped := true
+		for _, tr := range trs {
+			tr.IsUnexpected = false
+			if tr.Status != pb.TestResultStatus_SKIP {
+				allSkipped = false
+			}
+		}
+		// Make sure not all test results are SKIPPED, to avoid the status
+		// UNEXPECTEDLY_SKIPPED.
+		if allSkipped {
+			trs[0].Status = pb.TestResultStatus_CRASH
+		}
+	case pb.TestVerdictStatus_UNEXPECTEDLY_SKIPPED:
+		for _, tr := range trs {
+			tr.Status = pb.TestResultStatus_SKIP
+		}
+	case pb.TestVerdictStatus_FLAKY:
+		trs[0].IsUnexpected = false
+	default:
+		panic("status must be specified")
+	}
+}
+
+// applyRunStatus applies the given run status to the given test results.
+func applyRunStatus(trs []*TestResult, runStatus RunStatus) {
+	for _, tr := range trs {
+		tr.IsUnexpected = true
+	}
+	switch runStatus {
+	case Expected:
+		for _, tr := range trs {
+			tr.IsUnexpected = false
+		}
+	case Flaky:
+		trs[0].IsUnexpected = false
+	case Unexpected:
+		// All test results already unexpected.
+	}
+}
+
+func applyAvgPassedDuration(trs []*TestResult, passedAvgDuration *time.Duration) {
+	if passedAvgDuration == nil {
+		for _, tr := range trs {
+			if tr.Status == pb.TestResultStatus_PASS {
+				tr.RunDuration = nil
+			}
+		}
+		return
+	}
+
+	passCount := 0
+	for _, tr := range trs {
+		if tr.Status == pb.TestResultStatus_PASS {
+			passCount++
+		}
+	}
+	passIndex := 0
+	for _, tr := range trs {
+		if tr.Status == pb.TestResultStatus_PASS {
+			d := *passedAvgDuration
+			if passCount == 1 {
+				// If there is only one pass, assign it the
+				// set duration.
+				tr.RunDuration = &d
+				break
+			}
+			if passIndex == 0 && passCount%2 == 1 {
+				// If there are an odd number of passes, and
+				// more than one pass, assign the first pass
+				// a nil duration.
+				tr.RunDuration = nil
+			} else {
+				// Assigning alternating passes 2*d the duration
+				// and 0 duration, to keep the average correct.
+				if passIndex%2 == 0 {
+					d = d * 2
+					tr.RunDuration = &d
+				} else {
+					d = 0
+					tr.RunDuration = &d
+				}
+			}
+			passIndex++
+		}
+	}
+}
+
+func (b *TestVerdictBuilder) Build() []*TestResult {
+	runs := 2
+	if len(b.runStatuses) > 0 {
+		runs = len(b.runStatuses)
+	}
+
+	// Create two test results per run, to allow
+	// for all expected, all unexpected and
+	// flaky (mixed expected+unexpected) statuses
+	// to be represented.
+	trs := make([]*TestResult, 0, runs*2)
+	for i := 0; i < runs*2; i++ {
+		tr := new(TestResult)
+		*tr = b.baseResult
+		tr.RunIndex = int64(i / 2)
+		tr.ResultIndex = int64(i % 2)
+		trs = append(trs, tr)
+	}
+
+	// Normally only one of these should be set.
+	// If both are set, run statuses has precedence.
+	if b.status != nil {
+		applyStatus(trs, *b.status)
+	}
+	for i, runStatus := range b.runStatuses {
+		runTRs := trs[i*2 : (i+1)*2]
+		applyRunStatus(runTRs, runStatus)
+	}
+
+	applyAvgPassedDuration(trs, b.passedAvgDuration)
+	return trs
+}
diff --git a/analysis/internal/testutil/context.go b/analysis/internal/testutil/context.go
new file mode 100644
index 0000000..c5a7e13
--- /dev/null
+++ b/analysis/internal/testutil/context.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testutil
+
+import (
+	"context"
+
+	"go.chromium.org/luci/common/clock/testclock"
+	"go.chromium.org/luci/common/logging/gologger"
+)
+
+func testingContext(mockClock bool) context.Context {
+	ctx := context.Background()
+
+	// Enable logging to stdout/stderr.
+	ctx = gologger.StdConfig.Use(ctx)
+
+	if mockClock {
+		ctx, _ = testclock.UseTime(ctx, testclock.TestRecentTimeUTC)
+	}
+
+	return ctx
+}
+
+// TestingContext returns a context to be used in tests.
+func TestingContext() context.Context {
+	return testingContext(true)
+}
diff --git a/analysis/internal/testutil/insert/insert.go b/analysis/internal/testutil/insert/insert.go
new file mode 100644
index 0000000..213e413
--- /dev/null
+++ b/analysis/internal/testutil/insert/insert.go
@@ -0,0 +1,61 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package insert implements functions to insert rows for testing purposes.
+package insert
+
+import (
+	"time"
+
+	"cloud.google.com/go/spanner"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/span"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+)
+
+func updateDict(dest, source map[string]interface{}) {
+	for k, v := range source {
+		dest[k] = v
+	}
+}
+
+// AnalyzedTestVariant returns a spanner mutation that inserts an analyzed test variant.
+func AnalyzedTestVariant(realm, tId, vHash string, status atvpb.Status, extraValues map[string]interface{}) *spanner.Mutation {
+	values := map[string]interface{}{
+		"Realm":            realm,
+		"TestId":           tId,
+		"VariantHash":      vHash,
+		"Status":           status,
+		"CreateTime":       spanner.CommitTimestamp,
+		"StatusUpdateTime": spanner.CommitTimestamp,
+	}
+	updateDict(values, extraValues)
+	return span.InsertMap("AnalyzedTestVariants", values)
+}
+
+// Verdict returns a spanner mutation that inserts a Verdicts row.
+func Verdict(realm, tId, vHash, invID string, status internal.VerdictStatus, invTime time.Time, extraValues map[string]interface{}) *spanner.Mutation {
+	values := map[string]interface{}{
+		"Realm":                  realm,
+		"TestId":                 tId,
+		"VariantHash":            vHash,
+		"InvocationId":           invID,
+		"Status":                 status,
+		"InvocationCreationTime": invTime,
+		"IngestionTime":          invTime.Add(time.Hour),
+	}
+	updateDict(values, extraValues)
+	return span.InsertMap("Verdicts", values)
+}
diff --git a/analysis/internal/testutil/spantest.go b/analysis/internal/testutil/spantest.go
new file mode 100644
index 0000000..b1948f6
--- /dev/null
+++ b/analysis/internal/testutil/spantest.go
@@ -0,0 +1,97 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package testutil
+
+import (
+	"context"
+	"os"
+	"path/filepath"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/spantest"
+	"go.chromium.org/luci/server/span"
+)
+
+// cleanupDatabase deletes all data from all tables.
+func cleanupDatabase(ctx context.Context, client *spanner.Client) error {
+	_, err := client.Apply(ctx, []*spanner.Mutation{
+		// No need to explicitly delete interleaved tables.
+		spanner.Delete("AnalyzedTestVariants", spanner.AllKeys()),
+		spanner.Delete("ClusteringState", spanner.AllKeys()),
+		spanner.Delete("FailureAssociationRules", spanner.AllKeys()),
+		spanner.Delete("GitReferences", spanner.AllKeys()),
+		spanner.Delete("Ingestions", spanner.AllKeys()),
+		spanner.Delete("ReclusteringRuns", spanner.AllKeys()),
+		spanner.Delete("TestResults", spanner.AllKeys()),
+		spanner.Delete("IngestedInvocations", spanner.AllKeys()),
+		spanner.Delete("TestVariantRealms", spanner.AllKeys()),
+		spanner.Delete("TestRealms", spanner.AllKeys()),
+	})
+	return err
+}
+
+// SpannerTestContext returns a context for testing code that talks to Spanner.
+// Skips the test if integration tests are not enabled.
+//
+// Tests that use Spanner must not call t.Parallel().
+func SpannerTestContext(tb testing.TB) context.Context {
+	return spantest.SpannerTestContext(tb, cleanupDatabase)
+}
+
+// findInitScript returns path //weetbix/internal/span/init_db.sql.
+func findInitScript() (string, error) {
+	ancestor, err := filepath.Abs(".")
+	if err != nil {
+		return "", err
+	}
+
+	for {
+		scriptPath := filepath.Join(ancestor, "internal", "span", "init_db.sql")
+		_, err := os.Stat(scriptPath)
+		if os.IsNotExist(err) {
+			parent := filepath.Dir(ancestor)
+			if parent == ancestor {
+				return "", errors.Reason("init_db.sql not found").Err()
+			}
+			ancestor = parent
+			continue
+		}
+
+		return scriptPath, err
+	}
+}
+
+// SpannerTestMain is a test main function for packages that have tests that
+// talk to spanner. It creates/destroys a temporary spanner database
+// before/after running tests.
+//
+// This function never returns. Instead it calls os.Exit with the value returned
+// by m.Run().
+func SpannerTestMain(m *testing.M) {
+	spantest.SpannerTestMain(m, findInitScript)
+}
+
+// MustApply applies the mutations to the spanner client in the context.
+// Asserts that application succeeds.
+// Returns the commit timestamp.
+func MustApply(ctx context.Context, ms ...*spanner.Mutation) time.Time {
+	ct, err := span.Apply(ctx, ms)
+	So(err, ShouldBeNil)
+	return ct
+}
diff --git a/analysis/internal/verdict_status.go b/analysis/internal/verdict_status.go
new file mode 100644
index 0000000..dfd946c
--- /dev/null
+++ b/analysis/internal/verdict_status.go
@@ -0,0 +1,48 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package internal
+
+// Status of a Verdict.
+// It is determined by all the test results of the verdict, and exonerations are
+// ignored(i.e. failure is treated as a failure, even if it is exonerated).
+type VerdictStatus int32
+
+const (
+	// A verdict must not have this status.
+	// This is only used when filtering verdicts.
+	VerdictStatus_VERDICT_STATUS_UNSPECIFIED VerdictStatus = 0
+	// All results of the verdict are unexpected.
+	VerdictStatus_UNEXPECTED VerdictStatus = 10
+	// The verdict has both expected and unexpected results.
+	// To be differentiated with AnalyzedTestVariantStatus.FLAKY.
+	VerdictStatus_VERDICT_FLAKY VerdictStatus = 30
+	// All results of the verdict are expected.
+	VerdictStatus_EXPECTED VerdictStatus = 50
+)
+
+func (x VerdictStatus) String() string {
+	switch x {
+	case VerdictStatus_VERDICT_STATUS_UNSPECIFIED:
+		return "VERDICT_STATUS_UNSPECIFIED"
+	case VerdictStatus_UNEXPECTED:
+		return "UNEXPECTED"
+	case VerdictStatus_VERDICT_FLAKY:
+		return "VERDICT_FLAKY"
+	case VerdictStatus_EXPECTED:
+		return "EXPECTED"
+	default:
+		return "UNKNOWN"
+	}
+}
diff --git a/analysis/internal/verdicts/main_test.go b/analysis/internal/verdicts/main_test.go
new file mode 100644
index 0000000..11f7720
--- /dev/null
+++ b/analysis/internal/verdicts/main_test.go
@@ -0,0 +1,25 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package verdicts
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/internal/verdicts/span.go b/analysis/internal/verdicts/span.go
new file mode 100644
index 0000000..18de078
--- /dev/null
+++ b/analysis/internal/verdicts/span.go
@@ -0,0 +1,106 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package verdicts
+
+import (
+	"context"
+	"fmt"
+
+	"cloud.google.com/go/spanner"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/api/iterator"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	"go.chromium.org/luci/analysis/internal"
+	spanutil "go.chromium.org/luci/analysis/internal/span"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+)
+
+func statusCalculationDuration(du *durationpb.Duration) int {
+	return int(du.AsDuration().Hours())
+}
+
+// ComputeTestVariantStatusFromVerdicts computes the test variant's status based
+// on its verdicts within a time range.
+//
+// Currently the time range is the past one day, but should be configurable.
+// TODO(crbug.com/1259374): Use the value in configurations.
+func ComputeTestVariantStatusFromVerdicts(ctx context.Context, tvKey *taskspb.TestVariantKey, du *durationpb.Duration) (atvpb.Status, error) {
+	st := spanner.NewStatement(`
+		SELECT Status
+		FROM Verdicts@{FORCE_INDEX=VerdictsByTestVariantAndIngestionTime, spanner_emulator.disable_query_null_filtered_index_check=true}
+		WHERE Realm = @realm
+		AND TestId = @testID
+		AND VariantHash = @variantHash
+		AND IngestionTime >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL @numHours HOUR)
+	`)
+	st.Params = map[string]interface{}{
+		"realm":       tvKey.Realm,
+		"testID":      tvKey.TestId,
+		"variantHash": tvKey.VariantHash,
+		"numHours":    statusCalculationDuration(du),
+	}
+
+	totalCount := 0
+	unexpectedCount := 0
+
+	itr := span.Query(ctx, st)
+	defer itr.Stop()
+	var b spanutil.Buffer
+	for {
+		row, err := itr.Next()
+		if err == iterator.Done {
+			break
+		}
+		if err != nil {
+			return atvpb.Status_STATUS_UNSPECIFIED, err
+		}
+		var verdictStatus internal.VerdictStatus
+		if err = b.FromSpanner(row, &verdictStatus); err != nil {
+			return atvpb.Status_STATUS_UNSPECIFIED, err
+		}
+
+		totalCount++
+		switch verdictStatus {
+		case internal.VerdictStatus_VERDICT_FLAKY:
+			// Any flaky verdict means the test variant is flaky.
+			// Return status right away.
+			itr.Stop()
+			return atvpb.Status_FLAKY, nil
+		case internal.VerdictStatus_UNEXPECTED:
+			unexpectedCount++
+		case internal.VerdictStatus_EXPECTED:
+		default:
+			panic(fmt.Sprintf("got unsupported verdict status %d", int(verdictStatus)))
+		}
+	}
+
+	return computeTestVariantStatus(totalCount, unexpectedCount), nil
+}
+
+func computeTestVariantStatus(total, unexpected int) atvpb.Status {
+	switch {
+	case total == 0:
+		// No new results of the test variant.
+		return atvpb.Status_NO_NEW_RESULTS
+	case unexpected == 0:
+		return atvpb.Status_CONSISTENTLY_EXPECTED
+	case unexpected == total:
+		return atvpb.Status_CONSISTENTLY_UNEXPECTED
+	default:
+		return atvpb.Status_HAS_UNEXPECTED_RESULTS
+	}
+}
diff --git a/analysis/internal/verdicts/span_test.go b/analysis/internal/verdicts/span_test.go
new file mode 100644
index 0000000..e3afc37
--- /dev/null
+++ b/analysis/internal/verdicts/span_test.go
@@ -0,0 +1,121 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package verdicts
+
+import (
+	"testing"
+	"time"
+
+	"cloud.google.com/go/spanner"
+	"google.golang.org/protobuf/types/known/durationpb"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal"
+	"go.chromium.org/luci/analysis/internal/tasks/taskspb"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/internal/testutil/insert"
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+
+	. "github.com/smartystreets/goconvey/convey"
+)
+
+func TestComputeTestVariantStatusFromVerdicts(t *testing.T) {
+	Convey(`ComputeTestVariantStatusFromVerdicts`, t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		realm := "chromium:ci"
+		status := atvpb.Status_FLAKY
+		vh := "varianthash"
+
+		// Insert parent AnalyzedTestVariants.
+		ms := []*spanner.Mutation{
+			insert.AnalyzedTestVariant(realm, "ninja://still_flaky", vh, status, nil),
+			insert.AnalyzedTestVariant(realm, "ninja://consistently_expected", vh, status, nil),
+			insert.AnalyzedTestVariant(realm, "ninja://consistently_unexpected", vh, status, nil),
+			insert.AnalyzedTestVariant(realm, "ninja://has_unexpected_results", vh, status, nil),
+			insert.AnalyzedTestVariant(realm, "ninja://no_new_results", vh, status, nil),
+		}
+		testutil.MustApply(ctx, ms...)
+
+		test := func(tID string, expStatus atvpb.Status) {
+			ctx, cancel := span.ReadOnlyTransaction(ctx)
+			defer cancel()
+
+			tvKey := &taskspb.TestVariantKey{
+				Realm:       realm,
+				TestId:      tID,
+				VariantHash: vh,
+			}
+			tvStatus, err := ComputeTestVariantStatusFromVerdicts(ctx, tvKey, durationpb.New(24*time.Hour))
+			So(err, ShouldBeNil)
+			So(tvStatus, ShouldEqual, expStatus)
+		}
+
+		Convey(`still_flaky`, func() {
+			tID := "ninja://still_flaky"
+			ms := []*spanner.Mutation{
+				insert.Verdict(realm, tID, vh, "build-0", internal.VerdictStatus_EXPECTED, clock.Now(ctx).UTC().Add(-time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-1", internal.VerdictStatus_VERDICT_FLAKY, clock.Now(ctx).UTC().Add(-2*time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-2", internal.VerdictStatus_VERDICT_FLAKY, clock.Now(ctx).UTC().Add(-3*time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-3", internal.VerdictStatus_UNEXPECTED, clock.Now(ctx).UTC().Add(-4*time.Hour), nil),
+			}
+			testutil.MustApply(ctx, ms...)
+
+			test(tID, atvpb.Status_FLAKY)
+		})
+
+		Convey(`no_new_results`, func() {
+			tID := "ninja://no_new_results"
+			ms := []*spanner.Mutation{
+				insert.Verdict(realm, tID, vh, "build-0", internal.VerdictStatus_EXPECTED, clock.Now(ctx).UTC().Add(-25*time.Hour), nil),
+			}
+			testutil.MustApply(ctx, ms...)
+			test(tID, atvpb.Status_NO_NEW_RESULTS)
+		})
+
+		Convey(`consistently_unexpected`, func() {
+			tID := "ninja://consistently_unexpected"
+			ms := []*spanner.Mutation{
+				insert.Verdict(realm, tID, vh, "build-0", internal.VerdictStatus_VERDICT_FLAKY, clock.Now(ctx).UTC().Add(-26*time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-1", internal.VerdictStatus_UNEXPECTED, clock.Now(ctx).UTC().Add(-time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-2", internal.VerdictStatus_UNEXPECTED, clock.Now(ctx).UTC().Add(-2*time.Hour), nil),
+			}
+			testutil.MustApply(ctx, ms...)
+			test(tID, atvpb.Status_CONSISTENTLY_UNEXPECTED)
+		})
+
+		Convey(`consistently_expected`, func() {
+			tID := "ninja://consistently_expected"
+			ms := []*spanner.Mutation{
+				insert.Verdict(realm, tID, vh, "build-0", internal.VerdictStatus_EXPECTED, clock.Now(ctx).UTC().Add(-time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-1", internal.VerdictStatus_EXPECTED, clock.Now(ctx).UTC().Add(-2*time.Hour), nil),
+			}
+			testutil.MustApply(ctx, ms...)
+			test(tID, atvpb.Status_CONSISTENTLY_EXPECTED)
+		})
+
+		Convey(`has_unexpected_results`, func() {
+			tID := "ninja://has_unexpected_results"
+			ms := []*spanner.Mutation{
+				insert.Verdict(realm, tID, vh, "build-0", internal.VerdictStatus_EXPECTED, clock.Now(ctx).UTC().Add(-time.Hour), nil),
+				insert.Verdict(realm, tID, vh, "build-1", internal.VerdictStatus_UNEXPECTED, clock.Now(ctx).UTC().Add(-2*time.Hour), nil),
+			}
+			testutil.MustApply(ctx, ms...)
+			test(tID, atvpb.Status_HAS_UNEXPECTED_RESULTS)
+		})
+	})
+}
diff --git a/analysis/pbutil/common.go b/analysis/pbutil/common.go
new file mode 100644
index 0000000..2566ed2
--- /dev/null
+++ b/analysis/pbutil/common.go
@@ -0,0 +1,214 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package pbutil contains methods for manipulating Weetbix protos.
+package pbutil
+
+import (
+	"fmt"
+	"regexp"
+	"sort"
+	"time"
+
+	"go.chromium.org/luci/common/errors"
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+const maxStringPairKeyLength = 64
+const maxStringPairValueLength = 256
+const stringPairKeyPattern = `[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)*`
+
+var stringPairKeyRe = regexp.MustCompile(fmt.Sprintf(`^%s$`, stringPairKeyPattern))
+var stringPairRe = regexp.MustCompile(fmt.Sprintf("^(%s):(.*)$", stringPairKeyPattern))
+var variantHashRe = regexp.MustCompile("^[0-9a-f]{16}$")
+
+// MustTimestampProto converts a time.Time to a *timestamppb.Timestamp and panics
+// on failure.
+func MustTimestampProto(t time.Time) *timestamppb.Timestamp {
+	ts := timestamppb.New(t)
+	if err := ts.CheckValid(); err != nil {
+		panic(err)
+	}
+	return ts
+}
+
+// AsTime converts a *timestamppb.Timestamp to a time.Time.
+func AsTime(ts *timestamppb.Timestamp) (time.Time, error) {
+	if ts == nil {
+		return time.Time{}, errors.Reason("unspecified").Err()
+	}
+	if err := ts.CheckValid(); err != nil {
+		return time.Time{}, err
+	}
+	return ts.AsTime(), nil
+}
+
+func doesNotMatch(r *regexp.Regexp) error {
+	return errors.Reason("does not match %s", r).Err()
+}
+
+// StringPair creates a pb.StringPair with the given strings as key/value field values.
+func StringPair(k, v string) *pb.StringPair {
+	return &pb.StringPair{Key: k, Value: v}
+}
+
+// StringPairs creates a slice of pb.StringPair from a list of strings alternating key/value.
+//
+// Panics if an odd number of tokens is passed.
+func StringPairs(pairs ...string) []*pb.StringPair {
+	if len(pairs)%2 != 0 {
+		panic(fmt.Sprintf("odd number of tokens in %q", pairs))
+	}
+
+	strpairs := make([]*pb.StringPair, len(pairs)/2)
+	for i := range strpairs {
+		strpairs[i] = StringPair(pairs[2*i], pairs[2*i+1])
+	}
+	return strpairs
+}
+
+// StringPairFromString creates a pb.StringPair from the given key:val string.
+func StringPairFromString(s string) (*pb.StringPair, error) {
+	m := stringPairRe.FindStringSubmatch(s)
+	if m == nil {
+		return nil, doesNotMatch(stringPairRe)
+	}
+	return StringPair(m[1], m[3]), nil
+}
+
+// StringPairToString converts a StringPair to a key:val string.
+func StringPairToString(pair *pb.StringPair) string {
+	return fmt.Sprintf("%s:%s", pair.Key, pair.Value)
+}
+
+// StringPairsToStrings converts pairs to a slice of "{key}:{value}" strings
+// in the same order.
+func StringPairsToStrings(pairs ...*pb.StringPair) []string {
+	ret := make([]string, len(pairs))
+	for i, p := range pairs {
+		ret[i] = StringPairToString(p)
+	}
+	return ret
+}
+
+// Variant creates a pb.Variant from a list of strings alternating
+// key/value. Does not validate pairs.
+// See also VariantFromStrings.
+//
+// Panics if an odd number of tokens is passed.
+func Variant(pairs ...string) *pb.Variant {
+	if len(pairs)%2 != 0 {
+		panic(fmt.Sprintf("odd number of tokens in %q", pairs))
+	}
+
+	vr := &pb.Variant{Def: make(map[string]string, len(pairs)/2)}
+	for i := 0; i < len(pairs); i += 2 {
+		vr.Def[pairs[i]] = pairs[i+1]
+	}
+	return vr
+}
+
+// VariantFromStrings returns a Variant proto given the key:val string slice of its contents.
+//
+// If a key appears multiple times, the last pair wins.
+func VariantFromStrings(pairs []string) (*pb.Variant, error) {
+	if len(pairs) == 0 {
+		return nil, nil
+	}
+
+	def := make(map[string]string, len(pairs))
+	for _, p := range pairs {
+		pair, err := StringPairFromString(p)
+		if err != nil {
+			return nil, errors.Annotate(err, "pair %q", p).Err()
+		}
+		def[pair.Key] = pair.Value
+	}
+	return &pb.Variant{Def: def}, nil
+}
+
+// SortedVariantKeys returns the keys in the variant as a sorted slice.
+func SortedVariantKeys(vr *pb.Variant) []string {
+	keys := make([]string, 0, len(vr.GetDef()))
+	for k := range vr.GetDef() {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	return keys
+}
+
+var nonNilEmptyStringSlice = []string{}
+
+// VariantToStrings returns a key:val string slice representation of the Variant.
+// Never returns nil.
+func VariantToStrings(vr *pb.Variant) []string {
+	if len(vr.GetDef()) == 0 {
+		return nonNilEmptyStringSlice
+	}
+
+	keys := SortedVariantKeys(vr)
+	pairs := make([]string, len(keys))
+	defMap := vr.GetDef()
+	for i, k := range keys {
+		pairs[i] = (k + ":" + defMap[k])
+	}
+	return pairs
+}
+
+// VariantToStringPairs returns a slice of StringPair derived from *pb.Variant.
+func VariantToStringPairs(vr *pb.Variant) []*pb.StringPair {
+	defMap := vr.GetDef()
+	if len(defMap) == 0 {
+		return nil
+	}
+
+	keys := SortedVariantKeys(vr)
+	sp := make([]*pb.StringPair, len(keys))
+	for i, k := range keys {
+		sp[i] = StringPair(k, defMap[k])
+	}
+	return sp
+}
+
+// PresubmitRunModeFromString returns a pb.PresubmitRunMode corresponding
+// to a CV Run mode string.
+func PresubmitRunModeFromString(mode string) (pb.PresubmitRunMode, error) {
+	switch mode {
+	case "FULL_RUN":
+		return pb.PresubmitRunMode_FULL_RUN, nil
+	case "DRY_RUN":
+		return pb.PresubmitRunMode_DRY_RUN, nil
+	case "QUICK_DRY_RUN":
+		return pb.PresubmitRunMode_QUICK_DRY_RUN, nil
+	}
+	return pb.PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED, fmt.Errorf("unknown run mode %q", mode)
+}
+
+// PresubmitRunStatusFromLUCICV returns a pb.PresubmitRunStatus corresponding
+// to a LUCI CV Run status. Only statuses corresponding to an ended run
+// are supported.
+func PresubmitRunStatusFromLUCICV(status cvv0.Run_Status) (pb.PresubmitRunStatus, error) {
+	switch status {
+	case cvv0.Run_SUCCEEDED:
+		return pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED, nil
+	case cvv0.Run_FAILED:
+		return pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED, nil
+	case cvv0.Run_CANCELLED:
+		return pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_CANCELED, nil
+	}
+	return pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_UNSPECIFIED, fmt.Errorf("unknown run status %q", status)
+}
diff --git a/analysis/pbutil/common_test.go b/analysis/pbutil/common_test.go
new file mode 100644
index 0000000..ccb6d06
--- /dev/null
+++ b/analysis/pbutil/common_test.go
@@ -0,0 +1,61 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pbutil
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/cv/api/bigquery/v1"
+	cvv0 "go.chromium.org/luci/cv/api/v0"
+
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestCommon(t *testing.T) {
+	Convey("PresubmitRunModeFromString", t, func() {
+		// Confirm a mapping exists for every mode defined by LUCI CV.
+		// This test is designed to break if LUCI CV extends the set of
+		// allowed values, without a corresponding update to Weetbix.
+		for _, mode := range bigquery.Mode_name {
+			if mode == "MODE_UNSPECIFIED" {
+				continue
+			}
+			mode, err := PresubmitRunModeFromString(mode)
+			So(err, ShouldBeNil)
+			So(mode, ShouldNotEqual, pb.PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED)
+		}
+	})
+	Convey("PresubmitRunStatusFromLUCICV", t, func() {
+		// Confirm a mapping exists for every run status defined by LUCI CV.
+		// This test is designed to break if LUCI CV extends the set of
+		// allowed values, without a corresponding update to Weetbix.
+		for _, v := range cvv0.Run_Status_value {
+			runStatus := cvv0.Run_Status(v)
+			if runStatus&cvv0.Run_ENDED_MASK == 0 {
+				// Not a run ended status. Weetbix should not have to deal
+				// with these, as Weetbix only ingests completed runs.
+				continue
+			}
+			if runStatus == cvv0.Run_ENDED_MASK {
+				// The run ended mask is itself not a valid status.
+				continue
+			}
+			status, err := PresubmitRunStatusFromLUCICV(runStatus)
+			So(err, ShouldBeNil)
+			So(status, ShouldNotEqual, pb.PresubmitRunStatus_PRESUBMIT_RUN_STATUS_UNSPECIFIED)
+		}
+	})
+}
diff --git a/analysis/pbutil/predicate.go b/analysis/pbutil/predicate.go
new file mode 100644
index 0000000..4ad8470
--- /dev/null
+++ b/analysis/pbutil/predicate.go
@@ -0,0 +1,163 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package pbutil contains methods for manipulating Weetbix protos.
+package pbutil
+
+import (
+	"regexp"
+	"regexp/syntax"
+	"strings"
+
+	"go.chromium.org/luci/common/errors"
+
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+var (
+	// Unspecified is the error to be used when something is unpeicified when it's
+	// supposed to.
+	Unspecified = errors.Reason("unspecified").Err()
+
+	// DoesNotMatch is the error to be used when a string does not match a regex.
+	DoesNotMatch = errors.Reason("does not match").Err()
+)
+
+// validateRegexp returns a non-nil error if re is an invalid regular
+// expression.
+func validateRegexp(re string) error {
+	// Note: regexp.Compile uses syntax.Perl.
+	if _, err := syntax.Parse(re, syntax.Perl); err != nil {
+		return err
+	}
+
+	// Do not allow ^ and $ in the regexp, because we need to be able to prepend
+	// a pattern to the user-supplied pattern.
+	if strings.HasPrefix(re, "^") {
+		return errors.Reason("must not start with ^; it is prepended automatically").Err()
+	}
+	if strings.HasSuffix(re, "$") {
+		return errors.Reason("must not end with $; it is appended automatically").Err()
+	}
+
+	return nil
+}
+
+// ValidateWithRe validates a value matches the given re.
+func ValidateWithRe(re *regexp.Regexp, value string) error {
+	if value == "" {
+		return Unspecified
+	}
+	if !re.MatchString(value) {
+		return DoesNotMatch
+	}
+	return nil
+}
+
+// ValidateStringPair returns an error if p is invalid.
+func ValidateStringPair(p *pb.StringPair) error {
+	if err := ValidateWithRe(stringPairKeyRe, p.Key); err != nil {
+		return errors.Annotate(err, "key").Err()
+	}
+	if len(p.Key) > maxStringPairKeyLength {
+		return errors.Reason("key length must be less or equal to %d", maxStringPairKeyLength).Err()
+	}
+	if len(p.Value) > maxStringPairValueLength {
+		return errors.Reason("value length must be less or equal to %d", maxStringPairValueLength).Err()
+	}
+	return nil
+}
+
+// ValidateVariant returns an error if vr is invalid.
+func ValidateVariant(vr *pb.Variant) error {
+	for k, v := range vr.GetDef() {
+		p := pb.StringPair{Key: k, Value: v}
+		if err := ValidateStringPair(&p); err != nil {
+			return errors.Annotate(err, "%q:%q", k, v).Err()
+		}
+	}
+	return nil
+}
+
+// ValidateVariantPredicate returns a non-nil error if p is determined to be
+// invalid.
+func ValidateVariantPredicate(p *pb.VariantPredicate) error {
+	switch pr := p.Predicate.(type) {
+	case *pb.VariantPredicate_Equals:
+		return errors.Annotate(ValidateVariant(pr.Equals), "equals").Err()
+	case *pb.VariantPredicate_Contains:
+		return errors.Annotate(ValidateVariant(pr.Contains), "contains").Err()
+	case *pb.VariantPredicate_HashEquals:
+		return errors.Annotate(ValidateWithRe(variantHashRe, pr.HashEquals), "hash_equals").Err()
+	case nil:
+		return Unspecified
+	default:
+		panic("impossible")
+	}
+}
+
+// ValidateTestVerdictPredicate returns a non-nil error if p is determined to be
+// invalid.
+func ValidateTestVerdictPredicate(predicate *pb.TestVerdictPredicate) error {
+	if predicate == nil {
+		return Unspecified
+	}
+
+	if predicate.GetVariantPredicate() != nil {
+		if err := ValidateVariantPredicate(predicate.GetVariantPredicate()); err != nil {
+			return err
+		}
+	}
+	return ValidateEnum(int32(predicate.GetSubmittedFilter()), pb.SubmittedFilter_name)
+}
+
+// ValidateEnum returns a non-nil error if the value is not among valid values.
+func ValidateEnum(value int32, validValues map[int32]string) error {
+	if _, ok := validValues[value]; !ok {
+		return errors.Reason("invalid value %d", value).Err()
+	}
+	return nil
+}
+
+// ValidateAnalyzedTestVariantStatus returns a non-nil error if s is invalid
+// for a test variant.
+func ValidateAnalyzedTestVariantStatus(s atvpb.Status) error {
+	if err := ValidateEnum(int32(s), atvpb.Status_name); err != nil {
+		return err
+	}
+	return nil
+}
+
+// ValidateAnalyzedTestVariantPredicate returns a non-nil error if p is
+// determined to be invalid.
+func ValidateAnalyzedTestVariantPredicate(p *atvpb.Predicate) error {
+	if err := validateRegexp(p.GetTestIdRegexp()); err != nil {
+		return errors.Annotate(err, "test_id_regexp").Err()
+	}
+
+	if p.GetVariant() != nil {
+		if err := ValidateVariantPredicate(p.GetVariant()); err != nil {
+			return errors.Annotate(err, "variant").Err()
+		}
+	}
+
+	if p.GetStatus() == atvpb.Status_STATUS_UNSPECIFIED {
+		return nil
+	}
+	if err := ValidateAnalyzedTestVariantStatus(p.Status); err != nil {
+		return errors.Annotate(err, "status").Err()
+	}
+	return nil
+}
diff --git a/analysis/pbutil/predicate_test.go b/analysis/pbutil/predicate_test.go
new file mode 100644
index 0000000..d0898a5
--- /dev/null
+++ b/analysis/pbutil/predicate_test.go
@@ -0,0 +1,156 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pbutil
+
+import (
+	"strings"
+	"testing"
+
+	atvpb "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+)
+
+func TestValidateAnalyzedTestVariantPredicate(t *testing.T) {
+	Convey(`TestValidateAnalyzedTestVariantPredicate`, t, func() {
+		Convey(`Empty`, func() {
+			err := ValidateAnalyzedTestVariantPredicate(&atvpb.Predicate{})
+			So(err, ShouldBeNil)
+		})
+
+		Convey(`TestID`, func() {
+			validate := func(TestIdRegexp string) error {
+				return ValidateAnalyzedTestVariantPredicate(&atvpb.Predicate{
+					TestIdRegexp: TestIdRegexp,
+				})
+			}
+
+			Convey(`empty`, func() {
+				So(validate(""), ShouldBeNil)
+			})
+
+			Convey(`valid`, func() {
+				So(validate("A.+"), ShouldBeNil)
+			})
+
+			Convey(`invalid`, func() {
+				So(validate(")"), ShouldErrLike, "test_id_regexp: error parsing regex")
+			})
+			Convey(`^`, func() {
+				So(validate("^a"), ShouldErrLike, "test_id_regexp: must not start with ^")
+			})
+			Convey(`$`, func() {
+				So(validate("a$"), ShouldErrLike, "test_id_regexp: must not end with $")
+			})
+		})
+
+		Convey(`Status`, func() {
+			validate := func(s atvpb.Status) error {
+				return ValidateAnalyzedTestVariantPredicate(&atvpb.Predicate{
+					Status: s,
+				})
+			}
+			Convey(`unspecified`, func() {
+				err := validate(atvpb.Status_STATUS_UNSPECIFIED)
+				So(err, ShouldBeNil)
+			})
+			Convey(`invalid`, func() {
+				err := validate(atvpb.Status(100))
+				So(err, ShouldErrLike, `status: invalid value 100`)
+			})
+			Convey(`valid`, func() {
+				err := validate(atvpb.Status_FLAKY)
+				So(err, ShouldBeNil)
+			})
+		})
+	})
+}
+
+func TestValidateVariantPredicate(t *testing.T) {
+	Convey(`TestValidateVariantPredicate`, t, func() {
+		validVariant := Variant("a", "b")
+		invalidVariant := Variant("", "")
+
+		validate := func(p *pb.VariantPredicate) error {
+			return ValidateAnalyzedTestVariantPredicate(&atvpb.Predicate{
+				Variant: p,
+			})
+		}
+
+		Convey(`Equals`, func() {
+			Convey(`Valid`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Equals{Equals: validVariant},
+				})
+				So(err, ShouldBeNil)
+			})
+			Convey(`Invalid`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Equals{Equals: invalidVariant},
+				})
+				So(err, ShouldErrLike, `equals: "":"": key: unspecified`)
+			})
+		})
+
+		Convey(`Contains`, func() {
+			Convey(`Valid`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{Contains: validVariant},
+				})
+				So(err, ShouldBeNil)
+			})
+			Convey(`Invalid`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{Contains: invalidVariant},
+				})
+				So(err, ShouldErrLike, `contains: "":"": key: unspecified`)
+			})
+		})
+
+		Convey(`HashEquals`, func() {
+			Convey(`Valid`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_HashEquals{HashEquals: VariantHash(validVariant)},
+				})
+				So(err, ShouldBeNil)
+			})
+			Convey(`Empty string`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_HashEquals{HashEquals: ""},
+				})
+				So(err, ShouldErrLike, "hash_equals: unspecified")
+			})
+			Convey(`Upper case`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_HashEquals{HashEquals: strings.ToUpper(VariantHash(validVariant))},
+				})
+				So(err, ShouldErrLike, "hash_equals: does not match")
+			})
+			Convey(`Invalid length`, func() {
+				err := validate(&pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_HashEquals{HashEquals: VariantHash(validVariant)[1:]},
+				})
+				So(err, ShouldErrLike, "hash_equals: does not match")
+			})
+		})
+
+		Convey(`Unspecified`, func() {
+			err := validate(&pb.VariantPredicate{})
+			So(err, ShouldErrLike, `unspecified`)
+		})
+	})
+}
diff --git a/analysis/pbutil/resultdb.go b/analysis/pbutil/resultdb.go
new file mode 100644
index 0000000..1f2751e
--- /dev/null
+++ b/analysis/pbutil/resultdb.go
@@ -0,0 +1,134 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package pbutil contains methods for manipulating Weetbix protos.
+package pbutil
+
+import (
+	"go.chromium.org/luci/resultdb/pbutil"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// TestResultIDFromResultDB returns a Weetbix TestResultId corresponding to the
+// supplied ResultDB test result name.
+// The format of name should be:
+// "invocations/{INVOCATION_ID}/tests/{URL_ESCAPED_TEST_ID}/results/{RESULT_ID}".
+func TestResultIDFromResultDB(name string) *pb.TestResultId {
+	return &pb.TestResultId{System: "resultdb", Id: name}
+}
+
+// VariantFromResultDB returns a Weetbix Variant corresponding to the
+// supplied ResultDB Variant.
+func VariantFromResultDB(v *rdbpb.Variant) *pb.Variant {
+	if v == nil {
+		// Variant is optional in ResultDB.
+		return &pb.Variant{Def: make(map[string]string)}
+	}
+	return &pb.Variant{Def: v.Def}
+}
+
+// VariantToResultDB returns a ResultDB Variant corresponding to the
+// supplied Weetbix Variant.
+func VariantToResultDB(v *pb.Variant) *rdbpb.Variant {
+	if v == nil {
+		return &rdbpb.Variant{Def: make(map[string]string)}
+	}
+	return &rdbpb.Variant{Def: v.Def}
+}
+
+// VariantHash returns a hash of the variant.
+func VariantHash(v *pb.Variant) string {
+	return pbutil.VariantHash(VariantToResultDB(v))
+}
+
+// StringPairFromResultDB returns a Weetbix StringPair corresponding to the
+// supplied ResultDB StringPair.
+func StringPairFromResultDB(v []*rdbpb.StringPair) []*pb.StringPair {
+	pairs := []*pb.StringPair{}
+	for _, pair := range v {
+		pairs = append(pairs, &pb.StringPair{Key: pair.Key, Value: pair.Value})
+	}
+	return pairs
+}
+
+// FailureReasonFromResultDB returns a Weetbix FailureReason corresponding to the
+// supplied ResultDB FailureReason.
+func FailureReasonFromResultDB(fr *rdbpb.FailureReason) *pb.FailureReason {
+	if fr == nil {
+		return nil
+	}
+	return &pb.FailureReason{
+		PrimaryErrorMessage: fr.PrimaryErrorMessage,
+	}
+}
+
+// TestMetadataFromResultDB converts a ResultDB TestMetadata to a Weetbix
+// TestMetadata.
+func TestMetadataFromResultDB(rdbTmd *rdbpb.TestMetadata) *pb.TestMetadata {
+	if rdbTmd == nil {
+		return nil
+	}
+
+	tmd := &pb.TestMetadata{
+		Name: rdbTmd.Name,
+	}
+	loc := tmd.GetLocation()
+	if loc != nil {
+		tmd.Location = &pb.TestLocation{
+			Repo:     loc.Repo,
+			FileName: loc.FileName,
+			Line:     loc.Line,
+		}
+	}
+
+	return tmd
+}
+
+// TestResultStatus returns the Weetbix test result status corresponding
+// to the given ResultDB test result status.
+func TestResultStatusFromResultDB(s rdbpb.TestStatus) pb.TestResultStatus {
+	switch s {
+	case rdbpb.TestStatus_ABORT:
+		return pb.TestResultStatus_ABORT
+	case rdbpb.TestStatus_CRASH:
+		return pb.TestResultStatus_CRASH
+	case rdbpb.TestStatus_FAIL:
+		return pb.TestResultStatus_FAIL
+	case rdbpb.TestStatus_PASS:
+		return pb.TestResultStatus_PASS
+	case rdbpb.TestStatus_SKIP:
+		return pb.TestResultStatus_SKIP
+	default:
+		return pb.TestResultStatus_TEST_RESULT_STATUS_UNSPECIFIED
+	}
+}
+
+// ExonerationReasonFromResultDB converts a ResultDB ExonerationReason to a
+// Weetbix ExonerationReason.
+func ExonerationReasonFromResultDB(s rdbpb.ExonerationReason) pb.ExonerationReason {
+	switch s {
+	case rdbpb.ExonerationReason_NOT_CRITICAL:
+		return pb.ExonerationReason_NOT_CRITICAL
+	case rdbpb.ExonerationReason_OCCURS_ON_MAINLINE:
+		return pb.ExonerationReason_OCCURS_ON_MAINLINE
+	case rdbpb.ExonerationReason_OCCURS_ON_OTHER_CLS:
+		return pb.ExonerationReason_OCCURS_ON_OTHER_CLS
+	case rdbpb.ExonerationReason_UNEXPECTED_PASS:
+		return pb.ExonerationReason_UNEXPECTED_PASS
+	default:
+		return pb.ExonerationReason_EXONERATION_REASON_UNSPECIFIED
+	}
+}
diff --git a/analysis/pbutil/resultdb_test.go b/analysis/pbutil/resultdb_test.go
new file mode 100644
index 0000000..d1e6a91
--- /dev/null
+++ b/analysis/pbutil/resultdb_test.go
@@ -0,0 +1,55 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pbutil
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	rdbpb "go.chromium.org/luci/resultdb/proto/v1"
+
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestResultDB(t *testing.T) {
+	Convey("TestResultStatusFromResultDB", t, func() {
+		// Confirm Weetbix handles every test status defined by ResultDB.
+		// This test is designed to break if ResultDB extends the set of
+		// allowed values, without a corresponding update to Weetbix.
+		for _, v := range rdbpb.TestStatus_value {
+			rdbStatus := rdbpb.TestStatus(v)
+			if rdbStatus == rdbpb.TestStatus_STATUS_UNSPECIFIED {
+				continue
+			}
+
+			status := TestResultStatusFromResultDB(rdbStatus)
+			So(status, ShouldNotEqual, pb.TestResultStatus_TEST_RESULT_STATUS_UNSPECIFIED)
+		}
+	})
+	Convey("ExonerationReasonFromResultDB", t, func() {
+		// Confirm Weetbix handles every exoneration reason defined by ResultDB.
+		// This test is designed to break if ResultDB extends the set of
+		// allowed values, without a corresponding update to Weetbix.
+		for _, v := range rdbpb.ExonerationReason_value {
+			rdbReason := rdbpb.ExonerationReason(v)
+			if rdbReason == rdbpb.ExonerationReason_EXONERATION_REASON_UNSPECIFIED {
+				continue
+			}
+
+			reason := ExonerationReasonFromResultDB(rdbReason)
+			So(reason, ShouldNotEqual, pb.ExonerationReason_EXONERATION_REASON_UNSPECIFIED)
+		}
+	})
+}
diff --git a/analysis/proto/analyzedtestvariant/analyzed_test_variant.pb.go b/analysis/proto/analyzedtestvariant/analyzed_test_variant.pb.go
new file mode 100644
index 0000000..98d0e94
--- /dev/null
+++ b/analysis/proto/analyzedtestvariant/analyzed_test_variant.pb.go
@@ -0,0 +1,518 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.27.1
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/analyzedtestvariant/analyzed_test_variant.proto
+
+package atvpb
+
+import (
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Status of an analyzed test variant.
+type Status int32
+
+const (
+	// Status was not specified.
+	// Not to be used in actual test variants; serves as a default value for an unset field.
+	Status_STATUS_UNSPECIFIED Status = 0
+	// The test variant has unexpected results, but Weetbix cannot determine
+	// If it is FLAKY or CONSISTENTLY_UNEXPECTED.
+	// This status can be used when
+	// * in in-build flakiness cases, a test variant with flaky results in a build
+	//   is newly detected but the service has not been notified if the build
+	//   contributes to a CL's submission or not.
+	//   *  Note that this does not apply to Chromium flaky analysis because for
+	//      Chromium Weetbix only ingests test results from builds contribute to
+	//      CL submissions.
+	// * in cross-build flakiness cases, a test variant is newly detected in a build
+	//   where all of its results are unexpected.
+	Status_HAS_UNEXPECTED_RESULTS Status = 5
+	// The test variant is currently flaky.
+	Status_FLAKY Status = 10
+	// Results of the test variant have been consistently unexpected for
+	// a period of time.
+	Status_CONSISTENTLY_UNEXPECTED Status = 20
+	// Results of the test variant have been consistently expected for
+	// a period of time.
+	// TODO(chanli@): mention the configuration that specifies the time range.
+	Status_CONSISTENTLY_EXPECTED Status = 30
+	// There are no new results of the test variant for a period of time.
+	// It's likely that this test variant has been disabled or removed.
+	Status_NO_NEW_RESULTS Status = 40
+)
+
+// Enum value maps for Status.
+var (
+	Status_name = map[int32]string{
+		0:  "STATUS_UNSPECIFIED",
+		5:  "HAS_UNEXPECTED_RESULTS",
+		10: "FLAKY",
+		20: "CONSISTENTLY_UNEXPECTED",
+		30: "CONSISTENTLY_EXPECTED",
+		40: "NO_NEW_RESULTS",
+	}
+	Status_value = map[string]int32{
+		"STATUS_UNSPECIFIED":      0,
+		"HAS_UNEXPECTED_RESULTS":  5,
+		"FLAKY":                   10,
+		"CONSISTENTLY_UNEXPECTED": 20,
+		"CONSISTENTLY_EXPECTED":   30,
+		"NO_NEW_RESULTS":          40,
+	}
+)
+
+func (x Status) Enum() *Status {
+	p := new(Status)
+	*p = x
+	return p
+}
+
+func (x Status) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (Status) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_enumTypes[0].Descriptor()
+}
+
+func (Status) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_enumTypes[0]
+}
+
+func (x Status) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use Status.Descriptor instead.
+func (Status) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescGZIP(), []int{0}
+}
+
+// Flake statistics of a test variant.
+type FlakeStatistics struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Flake verdict rate calculated by the verdicts within the time range.
+	FlakyVerdictRate float32 `protobuf:"fixed32,1,opt,name=flaky_verdict_rate,json=flakyVerdictRate,proto3" json:"flaky_verdict_rate,omitempty"`
+	// Count of verdicts with flaky status.
+	FlakyVerdictCount int64 `protobuf:"varint,2,opt,name=flaky_verdict_count,json=flakyVerdictCount,proto3" json:"flaky_verdict_count,omitempty"`
+	// Count of total verdicts.
+	TotalVerdictCount int64 `protobuf:"varint,3,opt,name=total_verdict_count,json=totalVerdictCount,proto3" json:"total_verdict_count,omitempty"`
+	// Unexpected result rate calculated by the test results within the time range.
+	UnexpectedResultRate float32 `protobuf:"fixed32,4,opt,name=unexpected_result_rate,json=unexpectedResultRate,proto3" json:"unexpected_result_rate,omitempty"`
+	// Count of unexpected results.
+	UnexpectedResultCount int64 `protobuf:"varint,5,opt,name=unexpected_result_count,json=unexpectedResultCount,proto3" json:"unexpected_result_count,omitempty"`
+	// Count of total results.
+	TotalResultCount int64 `protobuf:"varint,6,opt,name=total_result_count,json=totalResultCount,proto3" json:"total_result_count,omitempty"`
+}
+
+func (x *FlakeStatistics) Reset() {
+	*x = FlakeStatistics{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FlakeStatistics) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FlakeStatistics) ProtoMessage() {}
+
+func (x *FlakeStatistics) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FlakeStatistics.ProtoReflect.Descriptor instead.
+func (*FlakeStatistics) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *FlakeStatistics) GetFlakyVerdictRate() float32 {
+	if x != nil {
+		return x.FlakyVerdictRate
+	}
+	return 0
+}
+
+func (x *FlakeStatistics) GetFlakyVerdictCount() int64 {
+	if x != nil {
+		return x.FlakyVerdictCount
+	}
+	return 0
+}
+
+func (x *FlakeStatistics) GetTotalVerdictCount() int64 {
+	if x != nil {
+		return x.TotalVerdictCount
+	}
+	return 0
+}
+
+func (x *FlakeStatistics) GetUnexpectedResultRate() float32 {
+	if x != nil {
+		return x.UnexpectedResultRate
+	}
+	return 0
+}
+
+func (x *FlakeStatistics) GetUnexpectedResultCount() int64 {
+	if x != nil {
+		return x.UnexpectedResultCount
+	}
+	return 0
+}
+
+func (x *FlakeStatistics) GetTotalResultCount() int64 {
+	if x != nil {
+		return x.TotalResultCount
+	}
+	return 0
+}
+
+type AnalyzedTestVariant struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Can be used to refer to this test variant.
+	// Format:
+	// "realms/{REALM}/tests/{URL_ESCAPED_TEST_ID}/variants/{VARIANT_HASH}"
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Realm that the test variant exists under.
+	// See https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/common/proto/realms/realms_config.proto
+	Realm string `protobuf:"bytes,2,opt,name=realm,proto3" json:"realm,omitempty"`
+	// Test id, identifier of the test. Unique in a LUCI realm.
+	TestId string `protobuf:"bytes,3,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Hash of the variant.
+	VariantHash string `protobuf:"bytes,4,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// Description of one specific way of running the test,
+	// e.g. a specific bucket, builder and a test suite.
+	Variant *v1.Variant `protobuf:"bytes,5,opt,name=variant,proto3" json:"variant,omitempty"`
+	// Information about the test at the time of its execution.
+	TestMetadata *v1.TestMetadata `protobuf:"bytes,6,opt,name=test_metadata,json=testMetadata,proto3" json:"test_metadata,omitempty"`
+	// Metadata for the test variant.
+	// See luci.resultdb.v1.Tags for details.
+	Tags []*v1.StringPair `protobuf:"bytes,7,rep,name=tags,proto3" json:"tags,omitempty"`
+	// A range of time. Flake statistics are calculated using test results
+	// within that range.
+	TimeRange *v1.TimeRange `protobuf:"bytes,8,opt,name=time_range,json=timeRange,proto3" json:"time_range,omitempty"`
+	// Status of the test valiant.
+	Status Status `protobuf:"varint,9,opt,name=status,proto3,enum=weetbix.analyzedtestvariant.Status" json:"status,omitempty"`
+	// Flakiness statistics of the test variant.
+	FlakeStatistics *FlakeStatistics `protobuf:"bytes,10,opt,name=flake_statistics,json=flakeStatistics,proto3" json:"flake_statistics,omitempty"`
+}
+
+func (x *AnalyzedTestVariant) Reset() {
+	*x = AnalyzedTestVariant{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *AnalyzedTestVariant) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AnalyzedTestVariant) ProtoMessage() {}
+
+func (x *AnalyzedTestVariant) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use AnalyzedTestVariant.ProtoReflect.Descriptor instead.
+func (*AnalyzedTestVariant) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *AnalyzedTestVariant) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *AnalyzedTestVariant) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *AnalyzedTestVariant) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *AnalyzedTestVariant) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *AnalyzedTestVariant) GetVariant() *v1.Variant {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *AnalyzedTestVariant) GetTestMetadata() *v1.TestMetadata {
+	if x != nil {
+		return x.TestMetadata
+	}
+	return nil
+}
+
+func (x *AnalyzedTestVariant) GetTags() []*v1.StringPair {
+	if x != nil {
+		return x.Tags
+	}
+	return nil
+}
+
+func (x *AnalyzedTestVariant) GetTimeRange() *v1.TimeRange {
+	if x != nil {
+		return x.TimeRange
+	}
+	return nil
+}
+
+func (x *AnalyzedTestVariant) GetStatus() Status {
+	if x != nil {
+		return x.Status
+	}
+	return Status_STATUS_UNSPECIFIED
+}
+
+func (x *AnalyzedTestVariant) GetFlakeStatistics() *FlakeStatistics {
+	if x != nil {
+		return x.FlakeStatistics
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDesc = []byte{
+	0x0a, 0x4d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x5f, 0x74, 0x65, 0x73,
+	0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
+	0x1b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65,
+	0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x1a, 0x1f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62,
+	0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f,
+	0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xbb, 0x02, 0x0a,
+	0x0f, 0x46, 0x6c, 0x61, 0x6b, 0x65, 0x53, 0x74, 0x61, 0x74, 0x69, 0x73, 0x74, 0x69, 0x63, 0x73,
+	0x12, 0x2c, 0x0a, 0x12, 0x66, 0x6c, 0x61, 0x6b, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63,
+	0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x02, 0x52, 0x10, 0x66, 0x6c,
+	0x61, 0x6b, 0x79, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x52, 0x61, 0x74, 0x65, 0x12, 0x2e,
+	0x0a, 0x13, 0x66, 0x6c, 0x61, 0x6b, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x5f,
+	0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x66, 0x6c, 0x61,
+	0x6b, 0x79, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2e,
+	0x0a, 0x13, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x5f,
+	0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x11, 0x74, 0x6f, 0x74,
+	0x61, 0x6c, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x34,
+	0x0a, 0x16, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x5f, 0x72, 0x61, 0x74, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x02, 0x52, 0x14,
+	0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
+	0x52, 0x61, 0x74, 0x65, 0x12, 0x36, 0x0a, 0x17, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74,
+	0x65, 0x64, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18,
+	0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x15, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65,
+	0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2c, 0x0a, 0x12,
+	0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x10, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x52,
+	0x65, 0x73, 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0xe9, 0x03, 0x0a, 0x13, 0x41,
+	0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61,
+	0x6e, 0x74, 0x12, 0x1a, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x42, 0x06, 0xe0, 0x41, 0x03, 0xe0, 0x41, 0x05, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14,
+	0x0a, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72,
+	0x65, 0x61, 0x6c, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a,
+	0x0c, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x04, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68,
+	0x12, 0x2d, 0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x56,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x12,
+	0x3d, 0x0a, 0x0d, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
+	0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
+	0x52, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a,
+	0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67,
+	0x50, 0x61, 0x69, 0x72, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x0a, 0x74, 0x69,
+	0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65,
+	0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65,
+	0x12, 0x3b, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0e,
+	0x32, 0x23, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79,
+	0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e, 0x53,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x57, 0x0a,
+	0x10, 0x66, 0x6c, 0x61, 0x6b, 0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x73, 0x74, 0x69, 0x63,
+	0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e, 0x46, 0x6c, 0x61, 0x6b, 0x65, 0x53, 0x74, 0x61, 0x74, 0x69,
+	0x73, 0x74, 0x69, 0x63, 0x73, 0x52, 0x0f, 0x66, 0x6c, 0x61, 0x6b, 0x65, 0x53, 0x74, 0x61, 0x74,
+	0x69, 0x73, 0x74, 0x69, 0x63, 0x73, 0x2a, 0x93, 0x01, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75,
+	0x73, 0x12, 0x16, 0x0a, 0x12, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50,
+	0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1a, 0x0a, 0x16, 0x48, 0x41, 0x53,
+	0x5f, 0x55, 0x4e, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x5f, 0x52, 0x45, 0x53, 0x55,
+	0x4c, 0x54, 0x53, 0x10, 0x05, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x4c, 0x41, 0x4b, 0x59, 0x10, 0x0a,
+	0x12, 0x1b, 0x0a, 0x17, 0x43, 0x4f, 0x4e, 0x53, 0x49, 0x53, 0x54, 0x45, 0x4e, 0x54, 0x4c, 0x59,
+	0x5f, 0x55, 0x4e, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, 0x14, 0x12, 0x19, 0x0a,
+	0x15, 0x43, 0x4f, 0x4e, 0x53, 0x49, 0x53, 0x54, 0x45, 0x4e, 0x54, 0x4c, 0x59, 0x5f, 0x45, 0x58,
+	0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, 0x1e, 0x12, 0x12, 0x0a, 0x0e, 0x4e, 0x4f, 0x5f, 0x4e,
+	0x45, 0x57, 0x5f, 0x52, 0x45, 0x53, 0x55, 0x4c, 0x54, 0x53, 0x10, 0x28, 0x42, 0x39, 0x5a, 0x37,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x6e,
+	0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e,
+	0x74, 0x3b, 0x61, 0x74, 0x76, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescData = file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_goTypes = []interface{}{
+	(Status)(0),                 // 0: weetbix.analyzedtestvariant.Status
+	(*FlakeStatistics)(nil),     // 1: weetbix.analyzedtestvariant.FlakeStatistics
+	(*AnalyzedTestVariant)(nil), // 2: weetbix.analyzedtestvariant.AnalyzedTestVariant
+	(*v1.Variant)(nil),          // 3: weetbix.v1.Variant
+	(*v1.TestMetadata)(nil),     // 4: weetbix.v1.TestMetadata
+	(*v1.StringPair)(nil),       // 5: weetbix.v1.StringPair
+	(*v1.TimeRange)(nil),        // 6: weetbix.v1.TimeRange
+}
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_depIdxs = []int32{
+	3, // 0: weetbix.analyzedtestvariant.AnalyzedTestVariant.variant:type_name -> weetbix.v1.Variant
+	4, // 1: weetbix.analyzedtestvariant.AnalyzedTestVariant.test_metadata:type_name -> weetbix.v1.TestMetadata
+	5, // 2: weetbix.analyzedtestvariant.AnalyzedTestVariant.tags:type_name -> weetbix.v1.StringPair
+	6, // 3: weetbix.analyzedtestvariant.AnalyzedTestVariant.time_range:type_name -> weetbix.v1.TimeRange
+	0, // 4: weetbix.analyzedtestvariant.AnalyzedTestVariant.status:type_name -> weetbix.analyzedtestvariant.Status
+	1, // 5: weetbix.analyzedtestvariant.AnalyzedTestVariant.flake_statistics:type_name -> weetbix.analyzedtestvariant.FlakeStatistics
+	6, // [6:6] is the sub-list for method output_type
+	6, // [6:6] is the sub-list for method input_type
+	6, // [6:6] is the sub-list for extension type_name
+	6, // [6:6] is the sub-list for extension extendee
+	0, // [0:6] is the sub-list for field type_name
+}
+
+func init() {
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_init()
+}
+func file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_init() {
+	if File_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FlakeStatistics); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*AnalyzedTestVariant); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDesc,
+			NumEnums:      1,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_depIdxs,
+		EnumInfos:         file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_enumTypes,
+		MessageInfos:      file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto = out.File
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_depIdxs = nil
+}
diff --git a/analysis/proto/analyzedtestvariant/analyzed_test_variant.proto b/analysis/proto/analyzedtestvariant/analyzed_test_variant.proto
new file mode 100644
index 0000000..5a7178c
--- /dev/null
+++ b/analysis/proto/analyzedtestvariant/analyzed_test_variant.proto
@@ -0,0 +1,121 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.analyzedtestvariant;
+
+import "google/api/field_behavior.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/analyzedtestvariant;atvpb";
+
+// Status of an analyzed test variant.
+enum Status {
+
+  // Status was not specified.
+  // Not to be used in actual test variants; serves as a default value for an unset field.
+  STATUS_UNSPECIFIED = 0;
+
+  // The test variant has unexpected results, but Weetbix cannot determine
+  // If it is FLAKY or CONSISTENTLY_UNEXPECTED.
+  // This status can be used when
+  // * in in-build flakiness cases, a test variant with flaky results in a build
+  //   is newly detected but the service has not been notified if the build
+  //   contributes to a CL's submission or not.
+  //   *  Note that this does not apply to Chromium flaky analysis because for
+  //      Chromium Weetbix only ingests test results from builds contribute to
+  //      CL submissions.
+  // * in cross-build flakiness cases, a test variant is newly detected in a build
+  //   where all of its results are unexpected.
+  HAS_UNEXPECTED_RESULTS = 5;
+
+  // The test variant is currently flaky.
+  FLAKY = 10;
+
+  // Results of the test variant have been consistently unexpected for
+  // a period of time.
+  CONSISTENTLY_UNEXPECTED = 20;
+
+
+  // Results of the test variant have been consistently expected for
+  // a period of time.
+  // TODO(chanli@): mention the configuration that specifies the time range.
+  CONSISTENTLY_EXPECTED = 30;
+
+  // There are no new results of the test variant for a period of time.
+  // It's likely that this test variant has been disabled or removed.
+  NO_NEW_RESULTS = 40;
+
+}
+
+// Flake statistics of a test variant.
+message FlakeStatistics {
+  // Flake verdict rate calculated by the verdicts within the time range.
+  float flaky_verdict_rate = 1;
+  // Count of verdicts with flaky status.
+  int64 flaky_verdict_count = 2;
+  // Count of total verdicts.
+  int64 total_verdict_count = 3;
+
+  // Unexpected result rate calculated by the test results within the time range.
+  float unexpected_result_rate = 4;
+  // Count of unexpected results.
+  int64 unexpected_result_count = 5;
+  // Count of total results.
+  int64 total_result_count = 6;
+}
+
+message AnalyzedTestVariant {
+  // Can be used to refer to this test variant.
+  // Format:
+  // "realms/{REALM}/tests/{URL_ESCAPED_TEST_ID}/variants/{VARIANT_HASH}"
+  string name = 1 [
+    (google.api.field_behavior) = OUTPUT_ONLY,
+    (google.api.field_behavior) = IMMUTABLE
+  ];
+
+  // Realm that the test variant exists under.
+  // See https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/common/proto/realms/realms_config.proto
+  string realm = 2;
+
+  // Test id, identifier of the test. Unique in a LUCI realm.
+  string test_id = 3;
+
+  // Hash of the variant.
+  string variant_hash = 4;
+
+  // Description of one specific way of running the test,
+  // e.g. a specific bucket, builder and a test suite.
+  weetbix.v1.Variant variant = 5;
+
+  // Information about the test at the time of its execution.
+  weetbix.v1.TestMetadata test_metadata = 6;
+
+  // Metadata for the test variant.
+  // See luci.resultdb.v1.Tags for details.
+  repeated weetbix.v1.StringPair tags = 7;
+
+  // A range of time. Flake statistics are calculated using test results
+  // within that range.
+  weetbix.v1.TimeRange time_range = 8;
+
+  // Status of the test valiant.
+  Status status = 9;
+
+  // Flakiness statistics of the test variant.
+  FlakeStatistics flake_statistics = 10;
+
+  // TODO(chanli@): Add Cluster and Bug information to the proto.
+}
diff --git a/analysis/proto/analyzedtestvariant/gen.go b/analysis/proto/analyzedtestvariant/gen.go
new file mode 100644
index 0000000..de54ef8
--- /dev/null
+++ b/analysis/proto/analyzedtestvariant/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package atvpb
+
+//go:generate cproto
diff --git a/analysis/proto/analyzedtestvariant/predicate.pb.go b/analysis/proto/analyzedtestvariant/predicate.pb.go
new file mode 100644
index 0000000..728550c
--- /dev/null
+++ b/analysis/proto/analyzedtestvariant/predicate.pb.go
@@ -0,0 +1,206 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.27.1
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/analyzedtestvariant/predicate.proto
+
+package atvpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Represents a function AnalyzedTestVariant -> bool.
+type Predicate struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A test variant must have a test id matching this regular expression
+	// entirely, i.e. the expression is implicitly wrapped with ^ and $.
+	TestIdRegexp string `protobuf:"bytes,1,opt,name=test_id_regexp,json=testIdRegexp,proto3" json:"test_id_regexp,omitempty"`
+	// A test variant must have a variant satisfying this predicate.
+	Variant *v1.VariantPredicate `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"`
+	// A test variant must have this status.
+	Status Status `protobuf:"varint,3,opt,name=status,proto3,enum=weetbix.analyzedtestvariant.Status" json:"status,omitempty"`
+}
+
+func (x *Predicate) Reset() {
+	*x = Predicate{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Predicate) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Predicate) ProtoMessage() {}
+
+func (x *Predicate) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Predicate.ProtoReflect.Descriptor instead.
+func (*Predicate) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Predicate) GetTestIdRegexp() string {
+	if x != nil {
+		return x.TestIdRegexp
+	}
+	return ""
+}
+
+func (x *Predicate) GetVariant() *v1.VariantPredicate {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *Predicate) GetStatus() Status {
+	if x != nil {
+		return x.Status
+	}
+	return Status_STATUS_UNSPECIFIED
+}
+
+var File_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDesc = []byte{
+	0x0a, 0x41, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x12, 0x1b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x61, 0x6e, 0x61,
+	0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x1a, 0x4d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x5f, 0x74, 0x65, 0x73,
+	0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a,
+	0x30, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65,
+	0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76,
+	0x31, 0x2f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x22, 0xa6, 0x01, 0x0a, 0x09, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12,
+	0x24, 0x0a, 0x0e, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x5f, 0x72, 0x65, 0x67, 0x65, 0x78,
+	0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x52,
+	0x65, 0x67, 0x65, 0x78, 0x70, 0x12, 0x36, 0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69,
+	0x63, 0x61, 0x74, 0x65, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x12, 0x3b, 0x0a,
+	0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x23, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64,
+	0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x39, 0x5a, 0x37, 0x69, 0x6e,
+	0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x6e, 0x61, 0x6c,
+	0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x3b,
+	0x61, 0x74, 0x76, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescData = file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_goTypes = []interface{}{
+	(*Predicate)(nil),           // 0: weetbix.analyzedtestvariant.Predicate
+	(*v1.VariantPredicate)(nil), // 1: weetbix.v1.VariantPredicate
+	(Status)(0),                 // 2: weetbix.analyzedtestvariant.Status
+}
+var file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_depIdxs = []int32{
+	1, // 0: weetbix.analyzedtestvariant.Predicate.variant:type_name -> weetbix.v1.VariantPredicate
+	2, // 1: weetbix.analyzedtestvariant.Predicate.status:type_name -> weetbix.analyzedtestvariant.Status
+	2, // [2:2] is the sub-list for method output_type
+	2, // [2:2] is the sub-list for method input_type
+	2, // [2:2] is the sub-list for extension type_name
+	2, // [2:2] is the sub-list for extension extendee
+	0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_init() }
+func file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_init() {
+	if File_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_analyzed_test_variant_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Predicate); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto = out.File
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_analyzedtestvariant_predicate_proto_depIdxs = nil
+}
diff --git a/analysis/proto/analyzedtestvariant/predicate.proto b/analysis/proto/analyzedtestvariant/predicate.proto
new file mode 100644
index 0000000..00f4b6d
--- /dev/null
+++ b/analysis/proto/analyzedtestvariant/predicate.proto
@@ -0,0 +1,35 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.analyzedtestvariant;
+
+import "go.chromium.org/luci/analysis/proto/analyzedtestvariant/analyzed_test_variant.proto";
+import "go.chromium.org/luci/analysis/proto/v1/predicate.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/analyzedtestvariant;atvpb";
+
+// Represents a function AnalyzedTestVariant -> bool.
+message Predicate {
+  // A test variant must have a test id matching this regular expression
+  // entirely, i.e. the expression is implicitly wrapped with ^ and $.
+  string test_id_regexp = 1;
+
+  // A test variant must have a variant satisfying this predicate.
+  weetbix.v1.VariantPredicate variant = 2;
+
+  // A test variant must have this status.
+  Status status = 3;
+}
diff --git a/analysis/proto/bq/clustered_failure_row.pb.go b/analysis/proto/bq/clustered_failure_row.pb.go
new file mode 100644
index 0000000..f3cccd2
--- /dev/null
+++ b/analysis/proto/bq/clustered_failure_row.pb.go
@@ -0,0 +1,842 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/bq/clustered_failure_row.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// ClusteredFailureRow represents a row in a BigQuery table for a clustered
+// test failure.
+// Next ID: 39.
+type ClusteredFailureRow struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The clustering algorithm which clustered the test failure.
+	ClusterAlgorithm string `protobuf:"bytes,1,opt,name=cluster_algorithm,json=clusterAlgorithm,proto3" json:"cluster_algorithm,omitempty"`
+	// The algorithm-defined cluster ID. Together with the cluster algorithm,
+	// this uniquely defines a cluster the test failure was clustered into.
+	//
+	// Note that each test failure may appear in multiple clusters (due to
+	// the presence of multiple clustering algorithms), but each clustering
+	// algorithm may only cluster the test result into one cluster.
+	//
+	// Note that the cluster ID is split over two fields (cluster_algorithm,
+	// cluster_id), rather than as one field with a record type, so that
+	// BigQuery clustering can be defined over the ID (not possible if a
+	// record type was used).
+	ClusterId string `protobuf:"bytes,2,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
+	// The test results system from which the test originated.
+	//
+	// Currently, the only valid value is "resultdb".
+	TestResultSystem string `protobuf:"bytes,3,opt,name=test_result_system,json=testResultSystem,proto3" json:"test_result_system,omitempty"`
+	// The identity of the test result in the test results system. Together
+	// with the test results sytstem, this uniquely identifies the test result
+	// that was clustered.
+	//
+	// For test results in ResultDB, the format is:
+	// "invocations/{INVOCATION_ID}/tests/{URL_ESCAPED_TEST_ID}/results/{RESULT_ID}"
+	// Where INVOCATION_ID, URL_ESCAPED_TEST_ID and RESULT_ID are values
+	// defined in ResultDB.
+	//
+	// Note that the test result ID is split over two fields (test_result_system,
+	// test_result_id), rather than as one field with a record type, so that
+	// BigQuery clustering can be defined over the ID (not possible if a
+	// record type was used).
+	TestResultId string `protobuf:"bytes,4,opt,name=test_result_id,json=testResultId,proto3" json:"test_result_id,omitempty"`
+	// Last Updated defines the version of test result-cluster inclusion status,
+	// as represented by this row. During its lifetime, due to changing
+	// failure association rules and clustering algorithm revisions, the
+	// clusters a test result is in may be updated.
+	//
+	// To achieve deletion in an append-optimised datastore like BigQuery,
+	// a new row will be exported for a given (cluster_algorithm, cluster_id,
+	// test_result_system, test_result_id) tuple with a later last_updated
+	// time that changes the is_included and/or is_included_with_high_priority
+	// fields. A scheduled query periodically purges superseded rows, to
+	// avoid excessive growth in the table.
+	//
+	// Clients should filter the rows they read to ensure they only use the
+	// rows with the latest last_updated time.
+	//
+	// The following is the definition of a view that correctly uses
+	// the last updated time column to query the table:
+	//   SELECT
+	//     ARRAY_AGG(cf ORDER BY last_updated DESC LIMIT 1)[OFFSET(0)] as row
+	//   FROM ${LUCI_PROJECT}.clustered_failures cf
+	//   -- Recommended: Apply restriction on partitions (e.g. last 14 days) as
+	//   -- desired.
+	//   -- WHERE partition_time >= TIMESTAMP_SUB(@as_at_time, INTERVAL 14 DAY)
+	//   GROUP BY cluster_algorithm, cluster_id, test_result_system, test_result_id
+	//   HAVING row.is_included
+	//
+	// This is based on the query design in [1].
+	// [1]: https://cloud.google.com/blog/products/bigquery/performing-large-scale-mutations-in-bigquery
+	LastUpdated *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"`
+	// The test result partition time identifies the beginning of the test
+	// result retention period, and corresponds approximately to the time
+	// the test result was produced.
+	//
+	// It is guaranteed that all test results from one presubmit run
+	// will have the same partition time. It is also guaranteed that all
+	// test results from one build will have the same partition time (in
+	// case of builds associated with presubmit runs this was implied by
+	// previous guarantee, but for testing that occurs outside presubmit
+	// this is an added guarantee).
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// Whether the test result is included in the cluster. Set to false if
+	// the test result has been removed from the cluster.
+	// False values appear in BigQuery as NULL.
+	IsIncluded bool `protobuf:"varint,7,opt,name=is_included,json=isIncluded,proto3" json:"is_included,omitempty"`
+	// Whether the test result is included in the cluster with high priority.
+	// True if either:
+	// 1. this cluster is a bug cluster (i.e. cluster defined by failure
+	//    association rule), OR
+	// 2. this cluster is a suggested cluster, and the test result is NOT
+	//    also in a bug cluster.
+	// False values appear in BigQuery as NULL.
+	IsIncludedWithHighPriority bool `protobuf:"varint,8,opt,name=is_included_with_high_priority,json=isIncludedWithHighPriority,proto3" json:"is_included_with_high_priority,omitempty"`
+	// The chunk this failure was processed and stored in. Assigned by
+	// Weetbix ingestion.
+	ChunkId string `protobuf:"bytes,9,opt,name=chunk_id,json=chunkId,proto3" json:"chunk_id,omitempty"`
+	// The zero-based index of this failure within the chunk. Assigned by
+	// Weetbix ingestion.
+	ChunkIndex int64 `protobuf:"varint,10,opt,name=chunk_index,json=chunkIndex,proto3" json:"chunk_index,omitempty"`
+	// Security realm of the test result.
+	// For test results from ResultDB, this must be set. The format is
+	// "{LUCI_PROJECT}:{REALM_SUFFIX}", for example "chromium:ci".
+	Realm string `protobuf:"bytes,11,opt,name=realm,proto3" json:"realm,omitempty"`
+	// The unique identifier of the test.
+	// For test results from ResultDB, see luci.resultdb.v1.TestResult.test_id.
+	TestId string `protobuf:"bytes,12,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// key:value pairs to specify the way of running a particular test.
+	// e.g. a specific bucket, builder and a test suite.
+	// For ResultDB, this is the known field.
+	Variant []*v1.StringPair `protobuf:"bytes,13,rep,name=variant,proto3" json:"variant,omitempty"`
+	// Metadata key value pairs for this test result.
+	// It might describe this particular execution or the test case.
+	// A key can be repeated.
+	Tags []*v1.StringPair `protobuf:"bytes,32,rep,name=tags,proto3" json:"tags,omitempty"`
+	// Hash of the variant.
+	// hex(sha256(''.join(sorted('%s:%s\n' for k, v in variant.items())))).
+	VariantHash string `protobuf:"bytes,14,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// A failure reason describing why the test failed.
+	FailureReason *v1.FailureReason `protobuf:"bytes,15,opt,name=failure_reason,json=failureReason,proto3" json:"failure_reason,omitempty"`
+	// The bug tracking component corresponding to this test case, as identified
+	// by the test results system. If no information is available, this is
+	// unset.
+	BugTrackingComponent *v1.BugTrackingComponent `protobuf:"bytes,16,opt,name=bug_tracking_component,json=bugTrackingComponent,proto3" json:"bug_tracking_component,omitempty"`
+	// The point in time when the test case started to execute.
+	StartTime *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"`
+	// The amount of time the test case took to execute, in seconds.
+	Duration float64 `protobuf:"fixed64,18,opt,name=duration,proto3" json:"duration,omitempty"`
+	// The exonerations applied to the test verdict.
+	// An empty list indicates the test verdict this test result was a part of
+	// was not exonerated.
+	Exonerations []*ClusteredFailureRow_TestExoneration `protobuf:"bytes,33,rep,name=exonerations,proto3" json:"exonerations,omitempty"`
+	// Identity of the presubmit run that contains this test result.
+	// This should be unique per "CQ+1"/"CQ+2" attempt on gerrit.
+	//
+	// One presumbit run MAY have many ingested invocation IDs (e.g. for its
+	// various tryjobs), but every ingested invocation ID only ever has one
+	// presubmit run ID (if any).
+	//
+	// All test results for the same presubmit run will have one
+	// partition_time.
+	//
+	// If the test result was not collected as part of a presubmit run,
+	// this is unset.
+	PresubmitRunId *v1.PresubmitRunId `protobuf:"bytes,20,opt,name=presubmit_run_id,json=presubmitRunId,proto3" json:"presubmit_run_id,omitempty"`
+	// The owner of the presubmit run (if any).
+	// This is the owner of the CL on which CQ+1/CQ+2 was clicked
+	// (even in case of presubmit run with multiple CLs).
+	// There is scope for this field to become an email address if privacy
+	// approval is obtained, until then it is "automation" (for automation
+	// service accounts) and "user" otherwise.
+	PresubmitRunOwner string `protobuf:"bytes,29,opt,name=presubmit_run_owner,json=presubmitRunOwner,proto3" json:"presubmit_run_owner,omitempty"`
+	// The mode of the presubmit run (if any).
+	// E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+	// If this test result does not relate to a presubmit run, this field
+	// is left as its default value (""). In BigQuery, this results in a
+	// NULL value.
+	PresubmitRunMode string `protobuf:"bytes,34,opt,name=presubmit_run_mode,json=presubmitRunMode,proto3" json:"presubmit_run_mode,omitempty"`
+	// The presubmit run's ending status.
+	// Notionally weetbix.v1.PresubmitRunStatus, but string so that
+	// we can chop off the "PRESUBMIT_RUN_STATUS_" prefix and have
+	// only the status, e.g. SUCCESS, FAILURE, CANCELED.
+	// If this test result does not relate to a presubmit run, this field
+	// is left as its default value (""). In BigQuery, this results in a
+	// NULL value.
+	PresubmitRunStatus string `protobuf:"bytes,35,opt,name=presubmit_run_status,json=presubmitRunStatus,proto3" json:"presubmit_run_status,omitempty"`
+	// The status of the build that contained this test result. Can be used
+	// to filter incomplete results (e.g. where build was cancelled or had
+	// an infra failure). Can also be used to filter builds with incomplete
+	// exonerations (e.g. build succeeded but some tests not exonerated).
+	// This is the build corresponding to ingested_invocation_id.
+	// Notionally weetbix.v1.BuildStatus, but string so that we can chop off
+	// the BUILD_STATUS_ prefix that would otherwise appear on every value.
+	BuildStatus string `protobuf:"bytes,36,opt,name=build_status,json=buildStatus,proto3" json:"build_status,omitempty"`
+	// Whether the build was critical to a presubmit run succeeding.
+	// If the build did not relate presubmit run (i.e. because it was a tryjob
+	// for a presubmit run), this is false.
+	// Note that both possible false values (from the build is not critical
+	// or because the build was not part of a presubmit run) appear in
+	// BigQuery as NULL.
+	// You can identify which of these cases applies by
+	// checking if presubmit_run_id is populated.
+	BuildCritical bool `protobuf:"varint,37,opt,name=build_critical,json=buildCritical,proto3" json:"build_critical,omitempty"`
+	// The unsubmitted changelists that were tested (if any).
+	// Changelists are sorted in ascending (host, change, patchset) order.
+	// Up to 10 changelists are captured.
+	Changelists []*v1.Changelist `protobuf:"bytes,38,rep,name=changelists,proto3" json:"changelists,omitempty"`
+	// The invocation from which this test result was ingested. This is
+	// the top-level invocation that was ingested, an "invocation" being
+	// a container of test results as identified by the source test result
+	// system.
+	//
+	// For ResultDB, Weetbix ingests invocations corresponding to
+	// buildbucket builds.
+	//
+	// All test results ingested from the same invocation (i.e. with the
+	// same ingested_invocation_id) will have the same partition time.
+	IngestedInvocationId string `protobuf:"bytes,21,opt,name=ingested_invocation_id,json=ingestedInvocationId,proto3" json:"ingested_invocation_id,omitempty"`
+	// The zero-based index for this test result, in the sequence of the
+	// ingested invocation's results for this test variant. Within the sequence,
+	// test results are ordered by start_time and then by test result ID.
+	// The first test result is 0, the last test result is
+	// ingested_invocation_result_count - 1.
+	IngestedInvocationResultIndex int64 `protobuf:"varint,22,opt,name=ingested_invocation_result_index,json=ingestedInvocationResultIndex,proto3" json:"ingested_invocation_result_index,omitempty"`
+	// The number of test results having this test variant in the ingested
+	// invocation.
+	IngestedInvocationResultCount int64 `protobuf:"varint,23,opt,name=ingested_invocation_result_count,json=ingestedInvocationResultCount,proto3" json:"ingested_invocation_result_count,omitempty"`
+	// Is the ingested invocation blocked by this test variant? This is
+	// only true if all (non-skipped) test results for this test variant
+	// (in the ingested invocation) are unexpected failures.
+	//
+	// Exoneration does not factor into this value; check is_exonerated
+	// to see if the impact of this ingested invocation being blocked was
+	// mitigated by exoneration.
+	IsIngestedInvocationBlocked bool `protobuf:"varint,24,opt,name=is_ingested_invocation_blocked,json=isIngestedInvocationBlocked,proto3" json:"is_ingested_invocation_blocked,omitempty"`
+	// The identifier of the test run the test ran in. Test results in different
+	// test runs are generally considered independent as they should be unable
+	// to leak state to one another.
+	//
+	// In Chrome and Chrome OS, a test run logically corresponds to a swarming
+	// task that runs tests, but this ID is not necessarily the ID of that
+	// task, but rather any other ID that is unique per such task.
+	//
+	// If test result system is ResultDB, this is the ID of the ResultDB
+	// invocation the test result was immediately contained within, not including
+	// any "invocations/" prefix.
+	TestRunId string `protobuf:"bytes,25,opt,name=test_run_id,json=testRunId,proto3" json:"test_run_id,omitempty"`
+	// The zero-based index for this test result, in the sequence of results
+	// having this test variant and test run. Within the sequence, test
+	// results are ordered by start_time and then by test result ID.
+	// The first test result is 0, the last test result is
+	// test_run_result_count - 1.
+	TestRunResultIndex int64 `protobuf:"varint,26,opt,name=test_run_result_index,json=testRunResultIndex,proto3" json:"test_run_result_index,omitempty"`
+	// The number of test results having this test variant and test run.
+	TestRunResultCount int64 `protobuf:"varint,27,opt,name=test_run_result_count,json=testRunResultCount,proto3" json:"test_run_result_count,omitempty"`
+	// Is the test run blocked by this test variant? This is only true if all
+	// (non-skipped) test results for this test variant (in the test run)
+	// are unexpected failures.
+	//
+	// Exoneration does not factor into this value; check is_exonerated
+	// to see if the impact of this test run being blocked was
+	// mitigated by exoneration.
+	IsTestRunBlocked bool `protobuf:"varint,28,opt,name=is_test_run_blocked,json=isTestRunBlocked,proto3" json:"is_test_run_blocked,omitempty"`
+}
+
+func (x *ClusteredFailureRow) Reset() {
+	*x = ClusteredFailureRow{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusteredFailureRow) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusteredFailureRow) ProtoMessage() {}
+
+func (x *ClusteredFailureRow) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusteredFailureRow.ProtoReflect.Descriptor instead.
+func (*ClusteredFailureRow) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ClusteredFailureRow) GetClusterAlgorithm() string {
+	if x != nil {
+		return x.ClusterAlgorithm
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetClusterId() string {
+	if x != nil {
+		return x.ClusterId
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetTestResultSystem() string {
+	if x != nil {
+		return x.TestResultSystem
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetTestResultId() string {
+	if x != nil {
+		return x.TestResultId
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetLastUpdated() *timestamppb.Timestamp {
+	if x != nil {
+		return x.LastUpdated
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetIsIncluded() bool {
+	if x != nil {
+		return x.IsIncluded
+	}
+	return false
+}
+
+func (x *ClusteredFailureRow) GetIsIncludedWithHighPriority() bool {
+	if x != nil {
+		return x.IsIncludedWithHighPriority
+	}
+	return false
+}
+
+func (x *ClusteredFailureRow) GetChunkId() string {
+	if x != nil {
+		return x.ChunkId
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetChunkIndex() int64 {
+	if x != nil {
+		return x.ChunkIndex
+	}
+	return 0
+}
+
+func (x *ClusteredFailureRow) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetVariant() []*v1.StringPair {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetTags() []*v1.StringPair {
+	if x != nil {
+		return x.Tags
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetFailureReason() *v1.FailureReason {
+	if x != nil {
+		return x.FailureReason
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetBugTrackingComponent() *v1.BugTrackingComponent {
+	if x != nil {
+		return x.BugTrackingComponent
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetStartTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.StartTime
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetDuration() float64 {
+	if x != nil {
+		return x.Duration
+	}
+	return 0
+}
+
+func (x *ClusteredFailureRow) GetExonerations() []*ClusteredFailureRow_TestExoneration {
+	if x != nil {
+		return x.Exonerations
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetPresubmitRunId() *v1.PresubmitRunId {
+	if x != nil {
+		return x.PresubmitRunId
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetPresubmitRunOwner() string {
+	if x != nil {
+		return x.PresubmitRunOwner
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetPresubmitRunMode() string {
+	if x != nil {
+		return x.PresubmitRunMode
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetPresubmitRunStatus() string {
+	if x != nil {
+		return x.PresubmitRunStatus
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetBuildStatus() string {
+	if x != nil {
+		return x.BuildStatus
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetBuildCritical() bool {
+	if x != nil {
+		return x.BuildCritical
+	}
+	return false
+}
+
+func (x *ClusteredFailureRow) GetChangelists() []*v1.Changelist {
+	if x != nil {
+		return x.Changelists
+	}
+	return nil
+}
+
+func (x *ClusteredFailureRow) GetIngestedInvocationId() string {
+	if x != nil {
+		return x.IngestedInvocationId
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetIngestedInvocationResultIndex() int64 {
+	if x != nil {
+		return x.IngestedInvocationResultIndex
+	}
+	return 0
+}
+
+func (x *ClusteredFailureRow) GetIngestedInvocationResultCount() int64 {
+	if x != nil {
+		return x.IngestedInvocationResultCount
+	}
+	return 0
+}
+
+func (x *ClusteredFailureRow) GetIsIngestedInvocationBlocked() bool {
+	if x != nil {
+		return x.IsIngestedInvocationBlocked
+	}
+	return false
+}
+
+func (x *ClusteredFailureRow) GetTestRunId() string {
+	if x != nil {
+		return x.TestRunId
+	}
+	return ""
+}
+
+func (x *ClusteredFailureRow) GetTestRunResultIndex() int64 {
+	if x != nil {
+		return x.TestRunResultIndex
+	}
+	return 0
+}
+
+func (x *ClusteredFailureRow) GetTestRunResultCount() int64 {
+	if x != nil {
+		return x.TestRunResultCount
+	}
+	return 0
+}
+
+func (x *ClusteredFailureRow) GetIsTestRunBlocked() bool {
+	if x != nil {
+		return x.IsTestRunBlocked
+	}
+	return false
+}
+
+type ClusteredFailureRow_TestExoneration struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Machine-readable reasons describing why the test failure was exonerated
+	// (if any).
+	Reason v1.ExonerationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=weetbix.v1.ExonerationReason" json:"reason,omitempty"`
+}
+
+func (x *ClusteredFailureRow_TestExoneration) Reset() {
+	*x = ClusteredFailureRow_TestExoneration{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusteredFailureRow_TestExoneration) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusteredFailureRow_TestExoneration) ProtoMessage() {}
+
+func (x *ClusteredFailureRow_TestExoneration) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusteredFailureRow_TestExoneration.ProtoReflect.Descriptor instead.
+func (*ClusteredFailureRow_TestExoneration) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescGZIP(), []int{0, 0}
+}
+
+func (x *ClusteredFailureRow_TestExoneration) GetReason() v1.ExonerationReason {
+	if x != nil {
+		return x.Reason
+	}
+	return v1.ExonerationReason(0)
+}
+
+var File_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDesc = []byte{
+	0x0a, 0x3c, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x62, 0x71, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x5f, 0x66, 0x61, 0x69,
+	0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x62, 0x71, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65,
+	0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x69, 0x6e, 0x66,
+	0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f,
+	0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x31, 0x69, 0x6e, 0x66, 0x72,
+	0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x68, 0x61,
+	0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x35, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f,
+	0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x22, 0xc6, 0x0e, 0x0a, 0x13, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x65, 0x64, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x6f, 0x77, 0x12, 0x2b, 0x0a, 0x11,
+	0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68,
+	0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x41, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x12, 0x74, 0x65, 0x73, 0x74,
+	0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x03,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x74, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
+	0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72,
+	0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c,
+	0x74, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49, 0x64, 0x12, 0x3d, 0x0a, 0x0c,
+	0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b,
+	0x6c, 0x61, 0x73, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x41, 0x0a, 0x0e, 0x70,
+	0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x06, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52,
+	0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x1f,
+	0x0a, 0x0b, 0x69, 0x73, 0x5f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x18, 0x07, 0x20,
+	0x01, 0x28, 0x08, 0x52, 0x0a, 0x69, 0x73, 0x49, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x12,
+	0x42, 0x0a, 0x1e, 0x69, 0x73, 0x5f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x77,
+	0x69, 0x74, 0x68, 0x5f, 0x68, 0x69, 0x67, 0x68, 0x5f, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74,
+	0x79, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x1a, 0x69, 0x73, 0x49, 0x6e, 0x63, 0x6c, 0x75,
+	0x64, 0x65, 0x64, 0x57, 0x69, 0x74, 0x68, 0x48, 0x69, 0x67, 0x68, 0x50, 0x72, 0x69, 0x6f, 0x72,
+	0x69, 0x74, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x64, 0x18,
+	0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x64, 0x12, 0x1f,
+	0x0a, 0x0b, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x0a, 0x20,
+	0x01, 0x28, 0x03, 0x52, 0x0a, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12,
+	0x14, 0x0a, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
+	0x72, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64,
+	0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x30,
+	0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32,
+	0x16, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72,
+	0x69, 0x6e, 0x67, 0x50, 0x61, 0x69, 0x72, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x12, 0x2a, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x20, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x69,
+	0x6e, 0x67, 0x50, 0x61, 0x69, 0x72, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x21, 0x0a, 0x0c,
+	0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x0e, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12,
+	0x40, 0x0a, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f,
+	0x6e, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x65, 0x61, 0x73,
+	0x6f, 0x6e, 0x52, 0x0d, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x65, 0x61, 0x73, 0x6f,
+	0x6e, 0x12, 0x56, 0x0a, 0x16, 0x62, 0x75, 0x67, 0x5f, 0x74, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e,
+	0x67, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x18, 0x10, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x42,
+	0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e,
+	0x65, 0x6e, 0x74, 0x52, 0x14, 0x62, 0x75, 0x67, 0x54, 0x72, 0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67,
+	0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61,
+	0x72, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x11, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e,
+	0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e,
+	0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74,
+	0x54, 0x69, 0x6d, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x18, 0x12, 0x20, 0x01, 0x28, 0x01, 0x52, 0x08, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e,
+	0x12, 0x53, 0x0a, 0x0c, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73,
+	0x18, 0x21, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x62, 0x71, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x46, 0x61, 0x69,
+	0x6c, 0x75, 0x72, 0x65, 0x52, 0x6f, 0x77, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x45, 0x78, 0x6f, 0x6e,
+	0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0c, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x44, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d,
+	0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x14, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1a, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65,
+	0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x52, 0x0e, 0x70, 0x72, 0x65,
+	0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x13, 0x70,
+	0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x6f, 0x77, 0x6e,
+	0x65, 0x72, 0x18, 0x1d, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62,
+	0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x4f, 0x77, 0x6e, 0x65, 0x72, 0x12, 0x2c, 0x0a, 0x12, 0x70,
+	0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x6d, 0x6f, 0x64,
+	0x65, 0x18, 0x22, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d,
+	0x69, 0x74, 0x52, 0x75, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x30, 0x0a, 0x14, 0x70, 0x72, 0x65,
+	0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75,
+	0x73, 0x18, 0x23, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d,
+	0x69, 0x74, 0x52, 0x75, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62,
+	0x75, 0x69, 0x6c, 0x64, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x24, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x25,
+	0x0a, 0x0e, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c,
+	0x18, 0x25, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x72, 0x69,
+	0x74, 0x69, 0x63, 0x61, 0x6c, 0x12, 0x38, 0x0a, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c,
+	0x69, 0x73, 0x74, 0x73, 0x18, 0x26, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69,
+	0x73, 0x74, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x12,
+	0x34, 0x0a, 0x16, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x15, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x14, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x47, 0x0a, 0x20, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65,
+	0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x5f, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x16, 0x20, 0x01, 0x28, 0x03, 0x52,
+	0x1d, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x47,
+	0x0a, 0x20, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x18, 0x17, 0x20, 0x01, 0x28, 0x03, 0x52, 0x1d, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74,
+	0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x75,
+	0x6c, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x43, 0x0a, 0x1e, 0x69, 0x73, 0x5f, 0x69, 0x6e,
+	0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x18, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x1b, 0x69, 0x73, 0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x1e, 0x0a, 0x0b,
+	0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x19, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x09, 0x74, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x12, 0x31, 0x0a, 0x15,
+	0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x5f,
+	0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x1a, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12, 0x74, 0x65, 0x73,
+	0x74, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x49, 0x6e, 0x64, 0x65, 0x78, 0x12,
+	0x31, 0x0a, 0x15, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x72, 0x65, 0x73, 0x75,
+	0x6c, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x1b, 0x20, 0x01, 0x28, 0x03, 0x52, 0x12,
+	0x74, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x43, 0x6f, 0x75,
+	0x6e, 0x74, 0x12, 0x2d, 0x0a, 0x13, 0x69, 0x73, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x75,
+	0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x1c, 0x20, 0x01, 0x28, 0x08, 0x52,
+	0x10, 0x69, 0x73, 0x54, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65,
+	0x64, 0x1a, 0x48, 0x0a, 0x0f, 0x54, 0x65, 0x73, 0x74, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61,
+	0x73, 0x6f, 0x6e, 0x52, 0x06, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x4a, 0x04, 0x08, 0x13, 0x10,
+	0x14, 0x4a, 0x04, 0x08, 0x1f, 0x10, 0x20, 0x4a, 0x04, 0x08, 0x1e, 0x10, 0x1f, 0x42, 0x2c, 0x5a,
+	0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65,
+	0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x62,
+	0x71, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescData = file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_goTypes = []interface{}{
+	(*ClusteredFailureRow)(nil),                 // 0: weetbix.bq.ClusteredFailureRow
+	(*ClusteredFailureRow_TestExoneration)(nil), // 1: weetbix.bq.ClusteredFailureRow.TestExoneration
+	(*timestamppb.Timestamp)(nil),               // 2: google.protobuf.Timestamp
+	(*v1.StringPair)(nil),                       // 3: weetbix.v1.StringPair
+	(*v1.FailureReason)(nil),                    // 4: weetbix.v1.FailureReason
+	(*v1.BugTrackingComponent)(nil),             // 5: weetbix.v1.BugTrackingComponent
+	(*v1.PresubmitRunId)(nil),                   // 6: weetbix.v1.PresubmitRunId
+	(*v1.Changelist)(nil),                       // 7: weetbix.v1.Changelist
+	(v1.ExonerationReason)(0),                   // 8: weetbix.v1.ExonerationReason
+}
+var file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_depIdxs = []int32{
+	2,  // 0: weetbix.bq.ClusteredFailureRow.last_updated:type_name -> google.protobuf.Timestamp
+	2,  // 1: weetbix.bq.ClusteredFailureRow.partition_time:type_name -> google.protobuf.Timestamp
+	3,  // 2: weetbix.bq.ClusteredFailureRow.variant:type_name -> weetbix.v1.StringPair
+	3,  // 3: weetbix.bq.ClusteredFailureRow.tags:type_name -> weetbix.v1.StringPair
+	4,  // 4: weetbix.bq.ClusteredFailureRow.failure_reason:type_name -> weetbix.v1.FailureReason
+	5,  // 5: weetbix.bq.ClusteredFailureRow.bug_tracking_component:type_name -> weetbix.v1.BugTrackingComponent
+	2,  // 6: weetbix.bq.ClusteredFailureRow.start_time:type_name -> google.protobuf.Timestamp
+	1,  // 7: weetbix.bq.ClusteredFailureRow.exonerations:type_name -> weetbix.bq.ClusteredFailureRow.TestExoneration
+	6,  // 8: weetbix.bq.ClusteredFailureRow.presubmit_run_id:type_name -> weetbix.v1.PresubmitRunId
+	7,  // 9: weetbix.bq.ClusteredFailureRow.changelists:type_name -> weetbix.v1.Changelist
+	8,  // 10: weetbix.bq.ClusteredFailureRow.TestExoneration.reason:type_name -> weetbix.v1.ExonerationReason
+	11, // [11:11] is the sub-list for method output_type
+	11, // [11:11] is the sub-list for method input_type
+	11, // [11:11] is the sub-list for extension type_name
+	11, // [11:11] is the sub-list for extension extendee
+	0,  // [0:11] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_init() }
+func file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_init() {
+	if File_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusteredFailureRow); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusteredFailureRow_TestExoneration); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto = out.File
+	file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_bq_clustered_failure_row_proto_depIdxs = nil
+}
diff --git a/analysis/proto/bq/clustered_failure_row.proto b/analysis/proto/bq/clustered_failure_row.proto
new file mode 100644
index 0000000..e469ab4
--- /dev/null
+++ b/analysis/proto/bq/clustered_failure_row.proto
@@ -0,0 +1,309 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.bq;
+
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+import "go.chromium.org/luci/analysis/proto/v1/changelist.proto";
+import "go.chromium.org/luci/analysis/proto/v1/failure_reason.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/bq;weetbixpb";
+
+// ClusteredFailureRow represents a row in a BigQuery table for a clustered
+// test failure.
+// Next ID: 39.
+message ClusteredFailureRow {
+  // The clustering algorithm which clustered the test failure.
+  string cluster_algorithm = 1;
+
+  // The algorithm-defined cluster ID. Together with the cluster algorithm,
+  // this uniquely defines a cluster the test failure was clustered into.
+  //
+  // Note that each test failure may appear in multiple clusters (due to
+  // the presence of multiple clustering algorithms), but each clustering
+  // algorithm may only cluster the test result into one cluster.
+  //
+  // Note that the cluster ID is split over two fields (cluster_algorithm,
+  // cluster_id), rather than as one field with a record type, so that
+  // BigQuery clustering can be defined over the ID (not possible if a
+  // record type was used).
+  string cluster_id = 2;
+
+  // The test results system from which the test originated.
+  //
+  // Currently, the only valid value is "resultdb".
+  string test_result_system = 3;
+
+  // The identity of the test result in the test results system. Together
+  // with the test results sytstem, this uniquely identifies the test result
+  // that was clustered.
+  //
+  // For test results in ResultDB, the format is:
+  // "invocations/{INVOCATION_ID}/tests/{URL_ESCAPED_TEST_ID}/results/{RESULT_ID}"
+  // Where INVOCATION_ID, URL_ESCAPED_TEST_ID and RESULT_ID are values
+  // defined in ResultDB.
+  //
+  // Note that the test result ID is split over two fields (test_result_system,
+  // test_result_id), rather than as one field with a record type, so that
+  // BigQuery clustering can be defined over the ID (not possible if a
+  // record type was used).
+  string test_result_id = 4;
+
+  // Last Updated defines the version of test result-cluster inclusion status,
+  // as represented by this row. During its lifetime, due to changing
+  // failure association rules and clustering algorithm revisions, the
+  // clusters a test result is in may be updated.
+  //
+  // To achieve deletion in an append-optimised datastore like BigQuery,
+  // a new row will be exported for a given (cluster_algorithm, cluster_id,
+  // test_result_system, test_result_id) tuple with a later last_updated
+  // time that changes the is_included and/or is_included_with_high_priority
+  // fields. A scheduled query periodically purges superseded rows, to
+  // avoid excessive growth in the table.
+  //
+  // Clients should filter the rows they read to ensure they only use the
+  // rows with the latest last_updated time.
+  //
+  // The following is the definition of a view that correctly uses
+  // the last updated time column to query the table:
+  //   SELECT
+  //     ARRAY_AGG(cf ORDER BY last_updated DESC LIMIT 1)[OFFSET(0)] as row
+  //   FROM ${LUCI_PROJECT}.clustered_failures cf
+  //   -- Recommended: Apply restriction on partitions (e.g. last 14 days) as
+  //   -- desired.
+  //   -- WHERE partition_time >= TIMESTAMP_SUB(@as_at_time, INTERVAL 14 DAY)
+  //   GROUP BY cluster_algorithm, cluster_id, test_result_system, test_result_id
+  //   HAVING row.is_included
+  //
+  // This is based on the query design in [1].
+  // [1]: https://cloud.google.com/blog/products/bigquery/performing-large-scale-mutations-in-bigquery
+  google.protobuf.Timestamp last_updated = 5;
+
+  // The test result partition time identifies the beginning of the test
+  // result retention period, and corresponds approximately to the time
+  // the test result was produced.
+  //
+  // It is guaranteed that all test results from one presubmit run
+  // will have the same partition time. It is also guaranteed that all
+  // test results from one build will have the same partition time (in
+  // case of builds associated with presubmit runs this was implied by
+  // previous guarantee, but for testing that occurs outside presubmit
+  // this is an added guarantee).
+  google.protobuf.Timestamp partition_time = 6;
+
+  // Whether the test result is included in the cluster. Set to false if
+  // the test result has been removed from the cluster.
+  // False values appear in BigQuery as NULL.
+  bool is_included = 7;
+
+  // Whether the test result is included in the cluster with high priority.
+  // True if either:
+  // 1. this cluster is a bug cluster (i.e. cluster defined by failure
+  //    association rule), OR
+  // 2. this cluster is a suggested cluster, and the test result is NOT
+  //    also in a bug cluster.
+  // False values appear in BigQuery as NULL.
+  bool is_included_with_high_priority = 8;
+
+  // The chunk this failure was processed and stored in. Assigned by
+  // Weetbix ingestion.
+  string chunk_id = 9;
+
+  // The zero-based index of this failure within the chunk. Assigned by
+  // Weetbix ingestion.
+  int64 chunk_index = 10;
+
+  // Security realm of the test result.
+  // For test results from ResultDB, this must be set. The format is
+  // "{LUCI_PROJECT}:{REALM_SUFFIX}", for example "chromium:ci".
+  string realm = 11;
+
+  // The unique identifier of the test.
+  // For test results from ResultDB, see luci.resultdb.v1.TestResult.test_id.
+  string test_id = 12;
+
+  // key:value pairs to specify the way of running a particular test.
+  // e.g. a specific bucket, builder and a test suite.
+  // For ResultDB, this is the known field.
+  repeated weetbix.v1.StringPair variant = 13;
+
+  // Metadata key value pairs for this test result.
+  // It might describe this particular execution or the test case.
+  // A key can be repeated.
+  repeated weetbix.v1.StringPair tags = 32;
+
+  // Hash of the variant.
+  // hex(sha256(''.join(sorted('%s:%s\n' for k, v in variant.items())))).
+  string variant_hash = 14;
+
+  // A failure reason describing why the test failed.
+  weetbix.v1.FailureReason failure_reason = 15;
+
+  // The bug tracking component corresponding to this test case, as identified
+  // by the test results system. If no information is available, this is
+  // unset.
+  weetbix.v1.BugTrackingComponent bug_tracking_component = 16;
+
+  // The point in time when the test case started to execute.
+  google.protobuf.Timestamp start_time = 17;
+
+  // The amount of time the test case took to execute, in seconds.
+  double duration = 18;
+
+  reserved 19;
+
+  reserved 31;
+
+  message TestExoneration {
+    // Machine-readable reasons describing why the test failure was exonerated
+    // (if any).
+    weetbix.v1.ExonerationReason reason = 1;
+  }
+
+  // The exonerations applied to the test verdict.
+  // An empty list indicates the test verdict this test result was a part of
+  // was not exonerated.
+  repeated TestExoneration exonerations = 33;
+
+  // Identity of the presubmit run that contains this test result.
+  // This should be unique per "CQ+1"/"CQ+2" attempt on gerrit.
+  //
+  // One presumbit run MAY have many ingested invocation IDs (e.g. for its
+  // various tryjobs), but every ingested invocation ID only ever has one
+  // presubmit run ID (if any).
+  //
+  // All test results for the same presubmit run will have one
+  // partition_time.
+  //
+  // If the test result was not collected as part of a presubmit run,
+  // this is unset.
+  weetbix.v1.PresubmitRunId presubmit_run_id = 20;
+
+  // The owner of the presubmit run (if any).
+  // This is the owner of the CL on which CQ+1/CQ+2 was clicked
+  // (even in case of presubmit run with multiple CLs).
+  // There is scope for this field to become an email address if privacy
+  // approval is obtained, until then it is "automation" (for automation
+  // service accounts) and "user" otherwise.
+  string presubmit_run_owner = 29;
+
+  // The mode of the presubmit run (if any).
+  // E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+  // If this test result does not relate to a presubmit run, this field
+  // is left as its default value (""). In BigQuery, this results in a
+  // NULL value.
+  string presubmit_run_mode = 34;
+
+  // The presubmit run's ending status.
+  // Notionally weetbix.v1.PresubmitRunStatus, but string so that
+  // we can chop off the "PRESUBMIT_RUN_STATUS_" prefix and have
+  // only the status, e.g. SUCCESS, FAILURE, CANCELED.
+  // If this test result does not relate to a presubmit run, this field
+  // is left as its default value (""). In BigQuery, this results in a
+  // NULL value.
+  string presubmit_run_status = 35;
+
+  reserved 30;
+
+  // The status of the build that contained this test result. Can be used
+  // to filter incomplete results (e.g. where build was cancelled or had
+  // an infra failure). Can also be used to filter builds with incomplete
+  // exonerations (e.g. build succeeded but some tests not exonerated).
+  // This is the build corresponding to ingested_invocation_id.
+  // Notionally weetbix.v1.BuildStatus, but string so that we can chop off
+  // the BUILD_STATUS_ prefix that would otherwise appear on every value.
+  string build_status = 36;
+
+  // Whether the build was critical to a presubmit run succeeding.
+  // If the build did not relate presubmit run (i.e. because it was a tryjob
+  // for a presubmit run), this is false.
+  // Note that both possible false values (from the build is not critical
+  // or because the build was not part of a presubmit run) appear in
+  // BigQuery as NULL.
+  // You can identify which of these cases applies by
+  // checking if presubmit_run_id is populated.
+  bool build_critical = 37;
+
+  // The unsubmitted changelists that were tested (if any).
+  // Changelists are sorted in ascending (host, change, patchset) order.
+  // Up to 10 changelists are captured.
+  repeated weetbix.v1.Changelist changelists = 38;
+
+  // The invocation from which this test result was ingested. This is
+  // the top-level invocation that was ingested, an "invocation" being
+  // a container of test results as identified by the source test result
+  // system.
+  //
+  // For ResultDB, Weetbix ingests invocations corresponding to
+  // buildbucket builds.
+  //
+  // All test results ingested from the same invocation (i.e. with the
+  // same ingested_invocation_id) will have the same partition time.
+  string ingested_invocation_id = 21;
+
+  // The zero-based index for this test result, in the sequence of the
+  // ingested invocation's results for this test variant. Within the sequence,
+  // test results are ordered by start_time and then by test result ID.
+  // The first test result is 0, the last test result is
+  // ingested_invocation_result_count - 1.
+  int64 ingested_invocation_result_index = 22;
+
+  // The number of test results having this test variant in the ingested
+  // invocation.
+  int64 ingested_invocation_result_count = 23;
+
+  // Is the ingested invocation blocked by this test variant? This is
+  // only true if all (non-skipped) test results for this test variant
+  // (in the ingested invocation) are unexpected failures.
+  //
+  // Exoneration does not factor into this value; check is_exonerated
+  // to see if the impact of this ingested invocation being blocked was
+  // mitigated by exoneration.
+  bool is_ingested_invocation_blocked = 24;
+
+  // The identifier of the test run the test ran in. Test results in different
+  // test runs are generally considered independent as they should be unable
+  // to leak state to one another.
+  //
+  // In Chrome and Chrome OS, a test run logically corresponds to a swarming
+  // task that runs tests, but this ID is not necessarily the ID of that
+  // task, but rather any other ID that is unique per such task.
+  //
+  // If test result system is ResultDB, this is the ID of the ResultDB
+  // invocation the test result was immediately contained within, not including
+  // any "invocations/" prefix.
+  string test_run_id = 25;
+
+  // The zero-based index for this test result, in the sequence of results
+  // having this test variant and test run. Within the sequence, test
+  // results are ordered by start_time and then by test result ID.
+  // The first test result is 0, the last test result is
+  // test_run_result_count - 1.
+  int64 test_run_result_index = 26;
+
+  // The number of test results having this test variant and test run.
+  int64 test_run_result_count = 27;
+
+  // Is the test run blocked by this test variant? This is only true if all
+  // (non-skipped) test results for this test variant (in the test run)
+  // are unexpected failures.
+  //
+  // Exoneration does not factor into this value; check is_exonerated
+  // to see if the impact of this test run being blocked was
+  // mitigated by exoneration.
+  bool is_test_run_blocked = 28;
+}
diff --git a/analysis/proto/bq/gen.go b/analysis/proto/bq/gen.go
new file mode 100644
index 0000000..8672e9b
--- /dev/null
+++ b/analysis/proto/bq/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package weetbixpb
+
+//go:generate cproto
diff --git a/analysis/proto/bq/test_variant_row.pb.go b/analysis/proto/bq/test_variant_row.pb.go
new file mode 100644
index 0000000..e6581ca
--- /dev/null
+++ b/analysis/proto/bq/test_variant_row.pb.go
@@ -0,0 +1,418 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/bq/test_variant_row.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	analyzedtestvariant "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	v1 "go.chromium.org/luci/analysis/proto/v1"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Verdict represent results of a test variant within an invocation.
+type Verdict struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Id of the invocation that contains the verdict.
+	Invocation string `protobuf:"bytes,1,opt,name=invocation,proto3" json:"invocation,omitempty"`
+	// Status of the verdict.
+	// String representation of weetbix.v1.VerdictStatus.
+	Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"`
+	// Invocation creation time.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"` // TODO: Add information about clusters and bugs.
+}
+
+func (x *Verdict) Reset() {
+	*x = Verdict{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Verdict) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Verdict) ProtoMessage() {}
+
+func (x *Verdict) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Verdict.ProtoReflect.Descriptor instead.
+func (*Verdict) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Verdict) GetInvocation() string {
+	if x != nil {
+		return x.Invocation
+	}
+	return ""
+}
+
+func (x *Verdict) GetStatus() string {
+	if x != nil {
+		return x.Status
+	}
+	return ""
+}
+
+func (x *Verdict) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+// TestVariantRow represents a row in a BigQuery table for a Weetbix analyzed
+// test variant.
+type TestVariantRow struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Can be used to refer to this test variant.
+	// Format:
+	// "realms/{REALM}/tests/{URL_ESCAPED_TEST_ID}/variants/{VARIANT_HASH}"
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Realm that the test variant exists under.
+	// See https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/common/proto/realms/realms_config.proto
+	Realm string `protobuf:"bytes,2,opt,name=realm,proto3" json:"realm,omitempty"`
+	// Test id, identifier of the test. Unique in a LUCI realm.
+	TestId string `protobuf:"bytes,3,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Hash of the variant.
+	VariantHash string `protobuf:"bytes,4,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// Description of one specific way of running the test,
+	// e.g. a specific bucket, builder and a test suite.
+	Variant []*v1.StringPair `protobuf:"bytes,5,rep,name=variant,proto3" json:"variant,omitempty"`
+	// Information about the test at the time of its execution.
+	TestMetadata *v1.TestMetadata `protobuf:"bytes,6,opt,name=test_metadata,json=testMetadata,proto3" json:"test_metadata,omitempty"`
+	// Metadata for the test variant.
+	// See luci.resultdb.v1.Tags for details.
+	Tags []*v1.StringPair `protobuf:"bytes,7,rep,name=tags,proto3" json:"tags,omitempty"`
+	// A range of time. Flake statistics are calculated using test results
+	// in the verdicts that were finalized within that range.
+	TimeRange *v1.TimeRange `protobuf:"bytes,8,opt,name=time_range,json=timeRange,proto3" json:"time_range,omitempty"`
+	// Status of the test variant.
+	// String representation of weetbix.analyzedtestvariant.Status.
+	Status string `protobuf:"bytes,9,opt,name=status,proto3" json:"status,omitempty"`
+	// Flakiness statistics of the test variant.
+	FlakeStatistics *analyzedtestvariant.FlakeStatistics `protobuf:"bytes,10,opt,name=flake_statistics,json=flakeStatistics,proto3" json:"flake_statistics,omitempty"`
+	// Verdicts of the test variant during the time range.
+	Verdicts []*Verdict `protobuf:"bytes,11,rep,name=verdicts,proto3" json:"verdicts,omitempty"`
+	// Partition_time is used to partition the table.
+	// It's the same as the latest of time_range.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+}
+
+func (x *TestVariantRow) Reset() {
+	*x = TestVariantRow{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantRow) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantRow) ProtoMessage() {}
+
+func (x *TestVariantRow) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantRow.ProtoReflect.Descriptor instead.
+func (*TestVariantRow) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *TestVariantRow) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *TestVariantRow) GetRealm() string {
+	if x != nil {
+		return x.Realm
+	}
+	return ""
+}
+
+func (x *TestVariantRow) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *TestVariantRow) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *TestVariantRow) GetVariant() []*v1.StringPair {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *TestVariantRow) GetTestMetadata() *v1.TestMetadata {
+	if x != nil {
+		return x.TestMetadata
+	}
+	return nil
+}
+
+func (x *TestVariantRow) GetTags() []*v1.StringPair {
+	if x != nil {
+		return x.Tags
+	}
+	return nil
+}
+
+func (x *TestVariantRow) GetTimeRange() *v1.TimeRange {
+	if x != nil {
+		return x.TimeRange
+	}
+	return nil
+}
+
+func (x *TestVariantRow) GetStatus() string {
+	if x != nil {
+		return x.Status
+	}
+	return ""
+}
+
+func (x *TestVariantRow) GetFlakeStatistics() *analyzedtestvariant.FlakeStatistics {
+	if x != nil {
+		return x.FlakeStatistics
+	}
+	return nil
+}
+
+func (x *TestVariantRow) GetVerdicts() []*Verdict {
+	if x != nil {
+		return x.Verdicts
+	}
+	return nil
+}
+
+func (x *TestVariantRow) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_proto_bq_test_variant_row_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDesc = []byte{
+	0x0a, 0x37, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x62, 0x71, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f,
+	0x72, 0x6f, 0x77, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x62, 0x71, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x4d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70,
+	0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65,
+	0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a,
+	0x65, 0x64, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70,
+	0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x22, 0x7e, 0x0a, 0x07, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x12,
+	0x1e, 0x0a, 0x0a, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12,
+	0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x3b, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74,
+	0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65,
+	0x54, 0x69, 0x6d, 0x65, 0x22, 0xae, 0x04, 0x0a, 0x0e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x52, 0x6f, 0x77, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x72,
+	0x65, 0x61, 0x6c, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x72, 0x65, 0x61, 0x6c,
+	0x6d, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x76, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x30, 0x0a,
+	0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x69,
+	0x6e, 0x67, 0x50, 0x61, 0x69, 0x72, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x12,
+	0x3d, 0x0a, 0x0d, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
+	0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61,
+	0x52, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a,
+	0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67,
+	0x50, 0x61, 0x69, 0x72, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, 0x12, 0x34, 0x0a, 0x0a, 0x74, 0x69,
+	0x6d, 0x65, 0x5f, 0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65,
+	0x52, 0x61, 0x6e, 0x67, 0x65, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65,
+	0x12, 0x16, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x57, 0x0a, 0x10, 0x66, 0x6c, 0x61, 0x6b,
+	0x65, 0x5f, 0x73, 0x74, 0x61, 0x74, 0x69, 0x73, 0x74, 0x69, 0x63, 0x73, 0x18, 0x0a, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x2c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x61, 0x6e, 0x61,
+	0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x2e, 0x46, 0x6c, 0x61, 0x6b, 0x65, 0x53, 0x74, 0x61, 0x74, 0x69, 0x73, 0x74, 0x69, 0x63, 0x73,
+	0x52, 0x0f, 0x66, 0x6c, 0x61, 0x6b, 0x65, 0x53, 0x74, 0x61, 0x74, 0x69, 0x73, 0x74, 0x69, 0x63,
+	0x73, 0x12, 0x2f, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73, 0x18, 0x0b, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x62, 0x71,
+	0x2e, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x52, 0x08, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63,
+	0x74, 0x73, 0x12, 0x41, 0x0a, 0x0e, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
+	0x74, 0x69, 0x6d, 0x65, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d,
+	0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f,
+	0x6e, 0x54, 0x69, 0x6d, 0x65, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61,
+	0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x62, 0x71, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescData = file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_goTypes = []interface{}{
+	(*Verdict)(nil),                             // 0: weetbix.bq.Verdict
+	(*TestVariantRow)(nil),                      // 1: weetbix.bq.TestVariantRow
+	(*timestamppb.Timestamp)(nil),               // 2: google.protobuf.Timestamp
+	(*v1.StringPair)(nil),                       // 3: weetbix.v1.StringPair
+	(*v1.TestMetadata)(nil),                     // 4: weetbix.v1.TestMetadata
+	(*v1.TimeRange)(nil),                        // 5: weetbix.v1.TimeRange
+	(*analyzedtestvariant.FlakeStatistics)(nil), // 6: weetbix.analyzedtestvariant.FlakeStatistics
+}
+var file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_depIdxs = []int32{
+	2, // 0: weetbix.bq.Verdict.create_time:type_name -> google.protobuf.Timestamp
+	3, // 1: weetbix.bq.TestVariantRow.variant:type_name -> weetbix.v1.StringPair
+	4, // 2: weetbix.bq.TestVariantRow.test_metadata:type_name -> weetbix.v1.TestMetadata
+	3, // 3: weetbix.bq.TestVariantRow.tags:type_name -> weetbix.v1.StringPair
+	5, // 4: weetbix.bq.TestVariantRow.time_range:type_name -> weetbix.v1.TimeRange
+	6, // 5: weetbix.bq.TestVariantRow.flake_statistics:type_name -> weetbix.analyzedtestvariant.FlakeStatistics
+	0, // 6: weetbix.bq.TestVariantRow.verdicts:type_name -> weetbix.bq.Verdict
+	2, // 7: weetbix.bq.TestVariantRow.partition_time:type_name -> google.protobuf.Timestamp
+	8, // [8:8] is the sub-list for method output_type
+	8, // [8:8] is the sub-list for method input_type
+	8, // [8:8] is the sub-list for extension type_name
+	8, // [8:8] is the sub-list for extension extendee
+	0, // [0:8] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_init() }
+func file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_init() {
+	if File_infra_appengine_weetbix_proto_bq_test_variant_row_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Verdict); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantRow); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_bq_test_variant_row_proto = out.File
+	file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_bq_test_variant_row_proto_depIdxs = nil
+}
diff --git a/analysis/proto/bq/test_variant_row.proto b/analysis/proto/bq/test_variant_row.proto
new file mode 100644
index 0000000..6e2488b
--- /dev/null
+++ b/analysis/proto/bq/test_variant_row.proto
@@ -0,0 +1,85 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.bq;
+
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/analyzedtestvariant/analyzed_test_variant.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/bq;weetbixpb";
+
+// Verdict represent results of a test variant within an invocation.
+message Verdict {
+  // Id of the invocation that contains the verdict.
+  string invocation = 1;
+
+  // Status of the verdict.
+  // String representation of weetbix.v1.VerdictStatus.
+  string status = 2;
+
+  // Invocation creation time.
+  google.protobuf.Timestamp create_time = 3;
+  // TODO: Add information about clusters and bugs.
+}
+
+// TestVariantRow represents a row in a BigQuery table for a Weetbix analyzed
+// test variant.
+message TestVariantRow {
+  // Can be used to refer to this test variant.
+  // Format:
+  // "realms/{REALM}/tests/{URL_ESCAPED_TEST_ID}/variants/{VARIANT_HASH}"
+  string name = 1;
+
+  // Realm that the test variant exists under.
+  // See https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/common/proto/realms/realms_config.proto
+  string realm = 2;
+
+  // Test id, identifier of the test. Unique in a LUCI realm.
+  string test_id = 3;
+
+  // Hash of the variant.
+  string variant_hash = 4;
+
+  // Description of one specific way of running the test,
+  // e.g. a specific bucket, builder and a test suite.
+  repeated weetbix.v1.StringPair variant = 5;
+
+  // Information about the test at the time of its execution.
+  weetbix.v1.TestMetadata test_metadata = 6;
+
+  // Metadata for the test variant.
+  // See luci.resultdb.v1.Tags for details.
+  repeated weetbix.v1.StringPair tags = 7;
+
+  // A range of time. Flake statistics are calculated using test results
+  // in the verdicts that were finalized within that range.
+  weetbix.v1.TimeRange time_range = 8;
+
+  // Status of the test variant.
+  // String representation of weetbix.analyzedtestvariant.Status.
+  string status = 9;
+
+  // Flakiness statistics of the test variant.
+  weetbix.analyzedtestvariant.FlakeStatistics flake_statistics = 10;
+
+  // Verdicts of the test variant during the time range.
+  repeated Verdict verdicts = 11;
+
+  // Partition_time is used to partition the table.
+  // It's the same as the latest of time_range.
+  google.protobuf.Timestamp partition_time = 12;
+}
diff --git a/analysis/proto/config/config.pb.go b/analysis/proto/config/config.pb.go
new file mode 100644
index 0000000..14c441a
--- /dev/null
+++ b/analysis/proto/config/config.pb.go
@@ -0,0 +1,224 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/config/config.proto
+
+package configpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Config is the service-wide configuration data for Weetbix.
+type Config struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The endpoint for Monorail APIs.
+	MonorailHostname string `protobuf:"bytes,1,opt,name=monorail_hostname,json=monorailHostname,proto3" json:"monorail_hostname,omitempty"`
+	// The GCS bucket that chunk contents should be archived to.
+	ChunkGcsBucket string `protobuf:"bytes,2,opt,name=chunk_gcs_bucket,json=chunkGcsBucket,proto3" json:"chunk_gcs_bucket,omitempty"`
+	// The number of workers to use when re-clustering. Maximum value is 1000,
+	// which is the default max_concurrent_requests on the reclustering queue:
+	// https://cloud.google.com/appengine/docs/standard/go111/config/queueref.
+	//
+	// If this is unset or zero, re-clustering is disabled.
+	ReclusteringWorkers int64 `protobuf:"varint,3,opt,name=reclustering_workers,json=reclusteringWorkers,proto3" json:"reclustering_workers,omitempty"`
+	// The frequency by which to re-cluster. This is specified as a
+	// number of minutes. Maximum value is 9, which is one minute less than
+	// the 10 minute hard request deadline for autoscaled GAE instances:
+	// https://cloud.google.com/appengine/docs/standard/go/how-instances-are-managed.
+	//
+	// If this is unset or zero, re-clustering is disabled.
+	ReclusteringIntervalMinutes int64 `protobuf:"varint,4,opt,name=reclustering_interval_minutes,json=reclusteringIntervalMinutes,proto3" json:"reclustering_interval_minutes,omitempty"`
+	// Controls whether Weetbix will interact with bug-filing systems.
+	// Can be used to stop Weetbix auto-bug filing and updates in
+	// response to a problem.
+	BugUpdatesEnabled bool `protobuf:"varint,5,opt,name=bug_updates_enabled,json=bugUpdatesEnabled,proto3" json:"bug_updates_enabled,omitempty"`
+}
+
+func (x *Config) Reset() {
+	*x = Config{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_config_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Config) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Config) ProtoMessage() {}
+
+func (x *Config) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_config_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Config.ProtoReflect.Descriptor instead.
+func (*Config) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_config_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Config) GetMonorailHostname() string {
+	if x != nil {
+		return x.MonorailHostname
+	}
+	return ""
+}
+
+func (x *Config) GetChunkGcsBucket() string {
+	if x != nil {
+		return x.ChunkGcsBucket
+	}
+	return ""
+}
+
+func (x *Config) GetReclusteringWorkers() int64 {
+	if x != nil {
+		return x.ReclusteringWorkers
+	}
+	return 0
+}
+
+func (x *Config) GetReclusteringIntervalMinutes() int64 {
+	if x != nil {
+		return x.ReclusteringIntervalMinutes
+	}
+	return 0
+}
+
+func (x *Config) GetBugUpdatesEnabled() bool {
+	if x != nil {
+		return x.BugUpdatesEnabled
+	}
+	return false
+}
+
+var File_infra_appengine_weetbix_proto_config_config_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_config_config_proto_rawDesc = []byte{
+	0x0a, 0x31, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x22, 0x86, 0x02, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2b,
+	0x0a, 0x11, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x6e,
+	0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x6d, 0x6f, 0x6e, 0x6f, 0x72,
+	0x61, 0x69, 0x6c, 0x48, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x28, 0x0a, 0x10, 0x63,
+	0x68, 0x75, 0x6e, 0x6b, 0x5f, 0x67, 0x63, 0x73, 0x5f, 0x62, 0x75, 0x63, 0x6b, 0x65, 0x74, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0e, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x47, 0x63, 0x73, 0x42,
+	0x75, 0x63, 0x6b, 0x65, 0x74, 0x12, 0x31, 0x0a, 0x14, 0x72, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x77, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x73, 0x18, 0x03, 0x20,
+	0x01, 0x28, 0x03, 0x52, 0x13, 0x72, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e,
+	0x67, 0x57, 0x6f, 0x72, 0x6b, 0x65, 0x72, 0x73, 0x12, 0x42, 0x0a, 0x1d, 0x72, 0x65, 0x63, 0x6c,
+	0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61,
+	0x6c, 0x5f, 0x6d, 0x69, 0x6e, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52,
+	0x1b, 0x72, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x74,
+	0x65, 0x72, 0x76, 0x61, 0x6c, 0x4d, 0x69, 0x6e, 0x75, 0x74, 0x65, 0x73, 0x12, 0x2e, 0x0a, 0x13,
+	0x62, 0x75, 0x67, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x73, 0x5f, 0x65, 0x6e, 0x61, 0x62,
+	0x6c, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x62, 0x75, 0x67, 0x55, 0x70,
+	0x64, 0x61, 0x74, 0x65, 0x73, 0x45, 0x6e, 0x61, 0x62, 0x6c, 0x65, 0x64, 0x42, 0x2f, 0x5a, 0x2d,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f,
+	0x6e, 0x66, 0x69, 0x67, 0x3b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x70, 0x62, 0x62, 0x06, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_config_config_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_config_config_proto_rawDescData = file_infra_appengine_weetbix_proto_config_config_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_config_config_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_config_config_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_config_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_config_config_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_config_config_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_config_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_proto_config_config_proto_goTypes = []interface{}{
+	(*Config)(nil), // 0: weetbix.config.Config
+}
+var file_infra_appengine_weetbix_proto_config_config_proto_depIdxs = []int32{
+	0, // [0:0] is the sub-list for method output_type
+	0, // [0:0] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_config_config_proto_init() }
+func file_infra_appengine_weetbix_proto_config_config_proto_init() {
+	if File_infra_appengine_weetbix_proto_config_config_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_config_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Config); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_config_config_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_config_config_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_config_config_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_config_config_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_config_config_proto = out.File
+	file_infra_appengine_weetbix_proto_config_config_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_config_config_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_config_config_proto_depIdxs = nil
+}
diff --git a/analysis/proto/config/config.proto b/analysis/proto/config/config.proto
new file mode 100644
index 0000000..07f7013
--- /dev/null
+++ b/analysis/proto/config/config.proto
@@ -0,0 +1,48 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.config;
+
+option go_package = "go.chromium.org/luci/analysis/proto/config;configpb";
+
+// Config is the service-wide configuration data for Weetbix.
+message Config {
+  // The endpoint for Monorail APIs.
+  string monorail_hostname = 1;
+
+  // The GCS bucket that chunk contents should be archived to.
+  string chunk_gcs_bucket = 2;
+
+  // The number of workers to use when re-clustering. Maximum value is 1000,
+  // which is the default max_concurrent_requests on the reclustering queue:
+  // https://cloud.google.com/appengine/docs/standard/go111/config/queueref.
+  //
+  // If this is unset or zero, re-clustering is disabled.
+  int64 reclustering_workers = 3;
+
+  // The frequency by which to re-cluster. This is specified as a
+  // number of minutes. Maximum value is 9, which is one minute less than
+  // the 10 minute hard request deadline for autoscaled GAE instances:
+  // https://cloud.google.com/appengine/docs/standard/go/how-instances-are-managed.
+  //
+  // If this is unset or zero, re-clustering is disabled.
+  int64 reclustering_interval_minutes = 4;
+
+  // Controls whether Weetbix will interact with bug-filing systems.
+  // Can be used to stop Weetbix auto-bug filing and updates in
+  // response to a problem.
+  bool bug_updates_enabled = 5;
+}
diff --git a/analysis/proto/config/gen.go b/analysis/proto/config/gen.go
new file mode 100644
index 0000000..b0ee3a0
--- /dev/null
+++ b/analysis/proto/config/gen.go
@@ -0,0 +1,17 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package configpb
+
+//go:generate cproto
diff --git a/analysis/proto/config/project_config.pb.go b/analysis/proto/config/project_config.pb.go
new file mode 100644
index 0000000..be9a787
--- /dev/null
+++ b/analysis/proto/config/project_config.pb.go
@@ -0,0 +1,1273 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/config/project_config.proto
+
+package configpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// ProjectConfig is the project-specific configuration data for Weetbix.
+type ProjectConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The project metadata (eg. display name).
+	ProjectMetadata *ProjectMetadata `protobuf:"bytes,6,opt,name=project_metadata,json=projectMetadata,proto3" json:"project_metadata,omitempty"`
+	// The monorail configuration to use when filing bugs.
+	Monorail *MonorailProject `protobuf:"bytes,1,opt,name=monorail,proto3" json:"monorail,omitempty"`
+	// The threshold at which to file bugs.
+	// If reason cluster's impact exceeds the given threshold,
+	// a bug will be filed for it.
+	// Alternatively, if test name cluster's impact exceeds 134% of the given
+	// threshold, a bug will also be filed for it.
+	//
+	// Weetbix's bias towards reason clusters reflects the fact that bugs
+	// filed for reasons should be better scoped and more actionable
+	// (focus on one problem).
+	BugFilingThreshold *ImpactThreshold `protobuf:"bytes,2,opt,name=bug_filing_threshold,json=bugFilingThreshold,proto3" json:"bug_filing_threshold,omitempty"`
+	// Per realm configurations.
+	Realms []*RealmConfig `protobuf:"bytes,3,rep,name=realms,proto3" json:"realms,omitempty"`
+	// The last time this project configuration was updated.
+	// Weetbix sets and stores this value internally. Do not set
+	// in your project's configuration file, it will be ignored.
+	LastUpdated *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=last_updated,json=lastUpdated,proto3" json:"last_updated,omitempty"`
+	// Configuration for how to cluster test results.
+	Clustering *Clustering `protobuf:"bytes,5,opt,name=clustering,proto3" json:"clustering,omitempty"`
+}
+
+func (x *ProjectConfig) Reset() {
+	*x = ProjectConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectConfig) ProtoMessage() {}
+
+func (x *ProjectConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectConfig.ProtoReflect.Descriptor instead.
+func (*ProjectConfig) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ProjectConfig) GetProjectMetadata() *ProjectMetadata {
+	if x != nil {
+		return x.ProjectMetadata
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetMonorail() *MonorailProject {
+	if x != nil {
+		return x.Monorail
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetBugFilingThreshold() *ImpactThreshold {
+	if x != nil {
+		return x.BugFilingThreshold
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetRealms() []*RealmConfig {
+	if x != nil {
+		return x.Realms
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetLastUpdated() *timestamppb.Timestamp {
+	if x != nil {
+		return x.LastUpdated
+	}
+	return nil
+}
+
+func (x *ProjectConfig) GetClustering() *Clustering {
+	if x != nil {
+		return x.Clustering
+	}
+	return nil
+}
+
+// ProjectMetadata provides data about the project that are mostly used in ui.
+type ProjectMetadata struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Indicates the preferred display name for the project in the UI.
+	// Deprecated: not used anymore.
+	DisplayName string `protobuf:"bytes,1,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+}
+
+func (x *ProjectMetadata) Reset() {
+	*x = ProjectMetadata{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectMetadata) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectMetadata) ProtoMessage() {}
+
+func (x *ProjectMetadata) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectMetadata.ProtoReflect.Descriptor instead.
+func (*ProjectMetadata) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ProjectMetadata) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+// MonorailProject describes the configuration to use when filing bugs
+// into a given monorail project.
+type MonorailProject struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The monorail project being described.
+	// E.g. "chromium".
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// The field values to use when creating new bugs.
+	// For example, on chromium issue tracker, there is a manadatory
+	// issue type field (field 10), which must be set to "Bug".
+	DefaultFieldValues []*MonorailFieldValue `protobuf:"bytes,2,rep,name=default_field_values,json=defaultFieldValues,proto3" json:"default_field_values,omitempty"`
+	// The ID of the issue's priority field. You can find this by visiting
+	// https://monorail-prod.appspot.com/p/<project>/adminLabels, scrolling
+	// down to Custom fields and finding the ID of the field you wish to set.
+	PriorityFieldId int64 `protobuf:"varint,3,opt,name=priority_field_id,json=priorityFieldId,proto3" json:"priority_field_id,omitempty"`
+	// The possible bug priorities and their associated impact thresholds.
+	// Priorities must be listed from highest (i.e. P0) to lowest (i.e. P3).
+	// Higher priorities can only be reached if the thresholds for all lower
+	// priorities are also met.
+	// The impact thresholds for setting the lowest priority implicitly
+	// identifies the bug closure threshold -- if no priority can be
+	// matched, the bug is closed. Satisfying the threshold for filing bugs MUST
+	// at least imply the threshold for the lowest priority, and MAY imply
+	// the thresholds of higher priorities.
+	Priorities []*MonorailPriority `protobuf:"bytes,4,rep,name=priorities,proto3" json:"priorities,omitempty"`
+	// Controls the amount of hysteresis used in setting bug priorities.
+	// Once a bug is assigned a given priority, its priority will only be
+	// increased if it exceeds the next priority's thresholds by the
+	// specified percentage margin, and decreased if the current priority's
+	// thresholds exceed the bug's impact by the given percentage margin.
+	//
+	// A value of 100 indicates impact may be double the threshold for
+	// the next highest priority value, (or half the threshold of the
+	// current priority value,) before a bug's priority is increased
+	// (or decreased).
+	//
+	// Valid values are from 0 (no hystersis) to 1,000 (10x hysteresis).
+	PriorityHysteresisPercent int64 `protobuf:"varint,5,opt,name=priority_hysteresis_percent,json=priorityHysteresisPercent,proto3" json:"priority_hysteresis_percent,omitempty"`
+	// The prefix that should appear when displaying bugs from the
+	// given bug tracking system. E.g. "crbug.com" or "fxbug.dev".
+	// If no prefix is specified, only the bug number will appear.
+	// Otherwise, the supplifed prefix will appear, followed by a
+	// forward slash ("/"), followed by the bug number.
+	// Valid prefixes match `^[a-z0-9\-.]{0,64}$`.
+	DisplayPrefix string `protobuf:"bytes,6,opt,name=display_prefix,json=displayPrefix,proto3" json:"display_prefix,omitempty"`
+	// The preferred hostname to use in links to monorail. For example,
+	// "bugs.chromium.org" or "bugs.fuchsia.dev".
+	MonorailHostname string `protobuf:"bytes,7,opt,name=monorail_hostname,json=monorailHostname,proto3" json:"monorail_hostname,omitempty"`
+}
+
+func (x *MonorailProject) Reset() {
+	*x = MonorailProject{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MonorailProject) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MonorailProject) ProtoMessage() {}
+
+func (x *MonorailProject) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MonorailProject.ProtoReflect.Descriptor instead.
+func (*MonorailProject) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *MonorailProject) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *MonorailProject) GetDefaultFieldValues() []*MonorailFieldValue {
+	if x != nil {
+		return x.DefaultFieldValues
+	}
+	return nil
+}
+
+func (x *MonorailProject) GetPriorityFieldId() int64 {
+	if x != nil {
+		return x.PriorityFieldId
+	}
+	return 0
+}
+
+func (x *MonorailProject) GetPriorities() []*MonorailPriority {
+	if x != nil {
+		return x.Priorities
+	}
+	return nil
+}
+
+func (x *MonorailProject) GetPriorityHysteresisPercent() int64 {
+	if x != nil {
+		return x.PriorityHysteresisPercent
+	}
+	return 0
+}
+
+func (x *MonorailProject) GetDisplayPrefix() string {
+	if x != nil {
+		return x.DisplayPrefix
+	}
+	return ""
+}
+
+func (x *MonorailProject) GetMonorailHostname() string {
+	if x != nil {
+		return x.MonorailHostname
+	}
+	return ""
+}
+
+// MonorailFieldValue describes a monorail field/value pair.
+type MonorailFieldValue struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The ID of the field to set. You can find this by visiting
+	// https://monorail-prod.appspot.com/p/<project>/adminLabels, scrolling
+	// down to Custom fields and finding the ID of the field you wish to set.
+	FieldId int64 `protobuf:"varint,1,opt,name=field_id,json=fieldId,proto3" json:"field_id,omitempty"`
+	// The field value. Values are encoded according to the field type:
+	// - Enumeration types: the string enumeration value (e.g. "Bug").
+	// - Integer types: the integer, converted to a string (e.g. "1052").
+	// - String types: the value, included verbatim.
+	// - User types: the user's resource name (e.g. "users/2627516260").
+	//   User IDs can be identified by looking at the people listing for a
+	//   project:  https://monorail-prod.appspot.com/p/<project>/people/list.
+	//   The User ID is included in the URL as u=<number> when clicking into
+	//   the page for a particular user. For example, "user/3816576959" is
+	//   https://monorail-prod.appspot.com/p/chromium/people/detail?u=3816576959.
+	// - Date types: the number of seconds since epoch, as a string
+	//   (e.g. "1609459200" for 1 January 2021).
+	// - URL type: the URL value, as a string (e.g. "https://www.google.com/").
+	//
+	// The source of truth for mapping of field types to values is as
+	// defined in the Monorail v3 API, found here:
+	// https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/api_proto/issue_objects.proto?q=%22message%20FieldValue%22
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+}
+
+func (x *MonorailFieldValue) Reset() {
+	*x = MonorailFieldValue{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MonorailFieldValue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MonorailFieldValue) ProtoMessage() {}
+
+func (x *MonorailFieldValue) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MonorailFieldValue.ProtoReflect.Descriptor instead.
+func (*MonorailFieldValue) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *MonorailFieldValue) GetFieldId() int64 {
+	if x != nil {
+		return x.FieldId
+	}
+	return 0
+}
+
+func (x *MonorailFieldValue) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+// MonorailPriority represents configuration for when to use a given
+// priority value in a bug.
+type MonorailPriority struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The monorail priority value. For example, "0". This depends on the
+	// valid priority field values you have defined in your monorail project.
+	Priority string `protobuf:"bytes,1,opt,name=priority,proto3" json:"priority,omitempty"`
+	// The threshold at which to apply the priority.
+	Threshold *ImpactThreshold `protobuf:"bytes,2,opt,name=threshold,proto3" json:"threshold,omitempty"`
+}
+
+func (x *MonorailPriority) Reset() {
+	*x = MonorailPriority{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MonorailPriority) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MonorailPriority) ProtoMessage() {}
+
+func (x *MonorailPriority) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MonorailPriority.ProtoReflect.Descriptor instead.
+func (*MonorailPriority) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *MonorailPriority) GetPriority() string {
+	if x != nil {
+		return x.Priority
+	}
+	return ""
+}
+
+func (x *MonorailPriority) GetThreshold() *ImpactThreshold {
+	if x != nil {
+		return x.Threshold
+	}
+	return nil
+}
+
+// ImpactThreshold specifies a condition on a cluster's impact metrics.
+// The threshold is considered satisfied if any of the individual metric
+// thresholds is met or exceeded (i.e. if multiple thresholds are set, they
+// are combined using an OR-semantic). If no threshold is set on any individual
+// metric, the threshold as a whole is unsatisfiable.
+type ImpactThreshold struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The number of test results that were unexpected failures.
+	TestResultsFailed *MetricThreshold `protobuf:"bytes,4,opt,name=test_results_failed,json=testResultsFailed,proto3" json:"test_results_failed,omitempty"`
+	// The number of test runs that failed.
+	// A test run (also known as a 'shard' (chromium) or 'task' (Chrome OS))
+	// is considered failed if all tries of test(s) in it unexpectedly failed.
+	// The failed test run is attributed to the last failure of each of the
+	// test(s) that failed on all tries.
+	TestRunsFailed *MetricThreshold `protobuf:"bytes,5,opt,name=test_runs_failed,json=testRunsFailed,proto3" json:"test_runs_failed,omitempty"`
+	// The number of presubmit runs that failed.
+	PresubmitRunsFailed *MetricThreshold `protobuf:"bytes,6,opt,name=presubmit_runs_failed,json=presubmitRunsFailed,proto3" json:"presubmit_runs_failed,omitempty"`
+	// The number of test failures on critical builders that were exonerated,
+	// with an exoneration reason other than NOT_CRITICAL.
+	CriticalFailuresExonerated *MetricThreshold `protobuf:"bytes,7,opt,name=critical_failures_exonerated,json=criticalFailuresExonerated,proto3" json:"critical_failures_exonerated,omitempty"`
+	// Deprecated. No longer has any effect. Retained for textproto
+	// compatibility only.
+	UnexpectedFailures_1D *int64 `protobuf:"varint,1,opt,name=unexpected_failures_1d,json=unexpectedFailures1d,proto3,oneof" json:"unexpected_failures_1d,omitempty"`
+	// Deprecated. No longer has any effect. Retained for textproto
+	// compatibility only.
+	UnexpectedFailures_3D *int64 `protobuf:"varint,2,opt,name=unexpected_failures_3d,json=unexpectedFailures3d,proto3,oneof" json:"unexpected_failures_3d,omitempty"`
+	// Deprecated. No longer has any effect. Retained for textproto
+	// compatibility only.
+	UnexpectedFailures_7D *int64 `protobuf:"varint,3,opt,name=unexpected_failures_7d,json=unexpectedFailures7d,proto3,oneof" json:"unexpected_failures_7d,omitempty"`
+}
+
+func (x *ImpactThreshold) Reset() {
+	*x = ImpactThreshold{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ImpactThreshold) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImpactThreshold) ProtoMessage() {}
+
+func (x *ImpactThreshold) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImpactThreshold.ProtoReflect.Descriptor instead.
+func (*ImpactThreshold) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ImpactThreshold) GetTestResultsFailed() *MetricThreshold {
+	if x != nil {
+		return x.TestResultsFailed
+	}
+	return nil
+}
+
+func (x *ImpactThreshold) GetTestRunsFailed() *MetricThreshold {
+	if x != nil {
+		return x.TestRunsFailed
+	}
+	return nil
+}
+
+func (x *ImpactThreshold) GetPresubmitRunsFailed() *MetricThreshold {
+	if x != nil {
+		return x.PresubmitRunsFailed
+	}
+	return nil
+}
+
+func (x *ImpactThreshold) GetCriticalFailuresExonerated() *MetricThreshold {
+	if x != nil {
+		return x.CriticalFailuresExonerated
+	}
+	return nil
+}
+
+func (x *ImpactThreshold) GetUnexpectedFailures_1D() int64 {
+	if x != nil && x.UnexpectedFailures_1D != nil {
+		return *x.UnexpectedFailures_1D
+	}
+	return 0
+}
+
+func (x *ImpactThreshold) GetUnexpectedFailures_3D() int64 {
+	if x != nil && x.UnexpectedFailures_3D != nil {
+		return *x.UnexpectedFailures_3D
+	}
+	return 0
+}
+
+func (x *ImpactThreshold) GetUnexpectedFailures_7D() int64 {
+	if x != nil && x.UnexpectedFailures_7D != nil {
+		return *x.UnexpectedFailures_7D
+	}
+	return 0
+}
+
+// MetricThreshold specifies thresholds for a particular metric.
+// The threshold is considered satisfied if any of the individual metric
+// thresholds is met or exceeded (i.e. if multiple thresholds are set, they
+// are combined using an OR-semantic). If no threshold is set, the threshold
+// as a whole is unsatisfiable.
+type MetricThreshold struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The threshold for one day.
+	OneDay *int64 `protobuf:"varint,1,opt,name=one_day,json=oneDay,proto3,oneof" json:"one_day,omitempty"`
+	// The threshold for three day.
+	ThreeDay *int64 `protobuf:"varint,2,opt,name=three_day,json=threeDay,proto3,oneof" json:"three_day,omitempty"`
+	// The threshold for seven days.
+	SevenDay *int64 `protobuf:"varint,3,opt,name=seven_day,json=sevenDay,proto3,oneof" json:"seven_day,omitempty"`
+}
+
+func (x *MetricThreshold) Reset() {
+	*x = MetricThreshold{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *MetricThreshold) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*MetricThreshold) ProtoMessage() {}
+
+func (x *MetricThreshold) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use MetricThreshold.ProtoReflect.Descriptor instead.
+func (*MetricThreshold) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *MetricThreshold) GetOneDay() int64 {
+	if x != nil && x.OneDay != nil {
+		return *x.OneDay
+	}
+	return 0
+}
+
+func (x *MetricThreshold) GetThreeDay() int64 {
+	if x != nil && x.ThreeDay != nil {
+		return *x.ThreeDay
+	}
+	return 0
+}
+
+func (x *MetricThreshold) GetSevenDay() int64 {
+	if x != nil && x.SevenDay != nil {
+		return *x.SevenDay
+	}
+	return 0
+}
+
+// Configurations per realm.
+type RealmConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Name of the realm.
+	//
+	// Must match `^[a-z0-9_\.\-/]{1,400}$`.
+	// Must not contain the project part. I.e. for "chromium:ci" realm the value
+	// here must be "ci".
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Test variant analysis configurations for the realm.
+	TestVariantAnalysis *TestVariantAnalysisConfig `protobuf:"bytes,2,opt,name=test_variant_analysis,json=testVariantAnalysis,proto3" json:"test_variant_analysis,omitempty"`
+}
+
+func (x *RealmConfig) Reset() {
+	*x = RealmConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *RealmConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RealmConfig) ProtoMessage() {}
+
+func (x *RealmConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RealmConfig.ProtoReflect.Descriptor instead.
+func (*RealmConfig) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *RealmConfig) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *RealmConfig) GetTestVariantAnalysis() *TestVariantAnalysisConfig {
+	if x != nil {
+		return x.TestVariantAnalysis
+	}
+	return nil
+}
+
+// Configuration for how test results are clustered.
+type Clustering struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Rules used to cluster test results by test name.
+	// The order of rules matters; the first matching rule will be used
+	// to cluster a given test result.
+	//
+	// If no rule matches, the test results will be clustered on the
+	// full test name. This corresponds approximately to the rule:
+	// {
+	//   name: "Full test name"
+	//   pattern: "^(?P<testname>.*)$"
+	//   like_template: "${testname}"
+	// }
+	TestNameRules []*TestNameClusteringRule `protobuf:"bytes,1,rep,name=test_name_rules,json=testNameRules,proto3" json:"test_name_rules,omitempty"`
+}
+
+func (x *Clustering) Reset() {
+	*x = Clustering{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Clustering) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Clustering) ProtoMessage() {}
+
+func (x *Clustering) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Clustering.ProtoReflect.Descriptor instead.
+func (*Clustering) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *Clustering) GetTestNameRules() []*TestNameClusteringRule {
+	if x != nil {
+		return x.TestNameRules
+	}
+	return nil
+}
+
+// A rule used to cluster a test result by test name.
+type TestNameClusteringRule struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A human-readable name for the rule. This should be unique for each rule.
+	// This may be used by Weetbix to explain why it chose to cluster the test
+	// name in this way.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The regular expression describing which test names should be clustered
+	// by this rule.
+	//
+	// Example.
+	//   Assume our project uploads google test (gtest) results with the test
+	//   name prefix "gtest://".
+	//   If want to cluster value-parameterized google tests
+	//   together based on the test suite and test case name (ignoring
+	//   the value parameter), we may use a pattern like:
+	//     "^gtest://(\w+/)?(?P<testcase>\w+\.\w+)/\w+$"
+	//
+	//   This will allow us to cluster test names like:
+	//     "gtest://InstantiationOne/ColorSpaceTest.testNullTransform/0"
+	//     "gtest://InstantiationOne/ColorSpaceTest.testNullTransform/1"
+	//     "gtest://InstantiationTwo/ColorSpaceTest.testNullTransform/0"
+	//   together.
+	//
+	//   See https://github.com/google/googletest/blob/main/docs/advanced.md#how-to-write-value-parameterized-tests
+	//   to understand value-parameterised google tests.
+	//
+	// Use ?P<name> to name capture groups, so their values can be used in
+	// like_template below.
+	Pattern string `protobuf:"bytes,2,opt,name=pattern,proto3" json:"pattern,omitempty"`
+	// The template used to generate a LIKE expression on test names
+	// that defines the test name cluster identified by this rule.
+	//
+	// This like expression has two purposes:
+	// (1) If the test name cluster is large enough to justify the
+	//     creation of a bug cluster, the like expression is used to
+	//     generate a failure association rule of the following form:
+	//        test LIKE "<evaluated like_template>"
+	// (2) A hash of the expression is used as the clustering key for the
+	//     test name-based suggested cluster. This generally has the desired
+	//     clustering behaviour, i.e. the parts of the test name which
+	//     are important enough to included in the LIKE expression for (1)
+	//     are also those on which clustering should occur.
+	//
+	// As is usual for LIKE expressions, the template can contain
+	// the following operators to do wildcard matching:
+	// * '%' for wildcard match of an arbitrary number of characters, and
+	// * '_' for single character wildcard match.
+	//
+	// To match literal '%' or '_', escape the operator with a '\',
+	// i.e. use "\%" or "\_" to match literal '%' and '_' respectively.
+	// To match literal '\', you should use "\\".
+	//
+	// The template can refer to parts of the test name matched by
+	// the rule pattern using ${name}, where name refers to the capture
+	// group (see pattern). To insert the literal '$', the sequence '$$'
+	// should be used.
+	//
+	// Example.
+	//   Assume our project uploads google test (gtest) results with the test
+	//   name prefix "gtest://". Further assume we used the pattern:
+	//     "^gtest://(\w+/)?(?P<testcase>\w+\.\w+)/\w+$"
+	//
+	//   We might use the following like_template:
+	//     "gtest://%${testcase}%"
+	//
+	//   When instantiated for a value-parameterised test, e.g.
+	//   "gtest://InstantiationOne/ColorSpaceTest.testNullTransform/0",
+	//   the result would be a failure association rule like:
+	//     test LIKE "gtest://%ColorSpaceTest.testNullTransform%"
+	//
+	//   Note the use of ${testcase} to refer to the testname capture group
+	//   specified in the pattern example.
+	//
+	//   See https://github.com/google/googletest/blob/main/docs/advanced.md#how-to-write-value-parameterized-tests
+	//   to understand value-parameterised google tests.
+	//
+	// It is known that not all clusters can be precisely matched by
+	// a LIKE expression. Nonetheless, Weetbix prefers LIKE expressions
+	// as they are easier to comprehend and modify by users, and in
+	// most cases, the added precision is not required.
+	//
+	// As such, your rule should try to ensure the generated LIKE statement
+	// captures your clustering logic as best it can. Your LIKE expression
+	// MUST match all test names matched by your regex pattern, and MAY
+	// capture additional test names (though this is preferably minimised,
+	// to reduce differences between the suggested clusters and eventual
+	// bug clusters).
+	//
+	// Weetbix will automatically escape any '%' '_' and '\' in parts of
+	// the matched test name before substitution to ensure captured parts
+	// of the test name are matched literally and not interpreted.
+	LikeTemplate string `protobuf:"bytes,3,opt,name=like_template,json=likeTemplate,proto3" json:"like_template,omitempty"`
+}
+
+func (x *TestNameClusteringRule) Reset() {
+	*x = TestNameClusteringRule{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestNameClusteringRule) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestNameClusteringRule) ProtoMessage() {}
+
+func (x *TestNameClusteringRule) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestNameClusteringRule.ProtoReflect.Descriptor instead.
+func (*TestNameClusteringRule) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *TestNameClusteringRule) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *TestNameClusteringRule) GetPattern() string {
+	if x != nil {
+		return x.Pattern
+	}
+	return ""
+}
+
+func (x *TestNameClusteringRule) GetLikeTemplate() string {
+	if x != nil {
+		return x.LikeTemplate
+	}
+	return ""
+}
+
+var File_infra_appengine_weetbix_proto_config_project_config_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_config_project_config_proto_rawDesc = []byte{
+	0x0a, 0x39, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x5f, 0x63,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x1a, 0x1f, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d,
+	0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x47, 0x69, 0x6e,
+	0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f,
+	0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x9b, 0x03, 0x0a, 0x0d, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x4a, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x5f, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x06, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66,
+	0x69, 0x67, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61,
+	0x74, 0x61, 0x52, 0x0f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64,
+	0x61, 0x74, 0x61, 0x12, 0x3b, 0x0a, 0x08, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x50,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c,
+	0x12, 0x51, 0x0a, 0x14, 0x62, 0x75, 0x67, 0x5f, 0x66, 0x69, 0x6c, 0x69, 0x6e, 0x67, 0x5f, 0x74,
+	0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
+	0x49, 0x6d, 0x70, 0x61, 0x63, 0x74, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52,
+	0x12, 0x62, 0x75, 0x67, 0x46, 0x69, 0x6c, 0x69, 0x6e, 0x67, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68,
+	0x6f, 0x6c, 0x64, 0x12, 0x33, 0x0a, 0x06, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x73, 0x18, 0x03, 0x20,
+	0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f,
+	0x6e, 0x66, 0x69, 0x67, 0x2e, 0x52, 0x65, 0x61, 0x6c, 0x6d, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67,
+	0x52, 0x06, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x73, 0x12, 0x3d, 0x0a, 0x0c, 0x6c, 0x61, 0x73, 0x74,
+	0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+	0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
+	0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x6c, 0x61, 0x73, 0x74,
+	0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x12, 0x3a, 0x0a, 0x0a, 0x63, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x43, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x0a, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x69, 0x6e, 0x67, 0x22, 0x34, 0x0a, 0x0f, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x4d, 0x65,
+	0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61,
+	0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x69,
+	0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x83, 0x03, 0x0a, 0x0f, 0x4d, 0x6f,
+	0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x18, 0x0a,
+	0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
+	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x54, 0x0a, 0x14, 0x64, 0x65, 0x66, 0x61, 0x75,
+	0x6c, 0x74, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x18,
+	0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x46,
+	0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x52, 0x12, 0x64, 0x65, 0x66, 0x61, 0x75,
+	0x6c, 0x74, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x2a, 0x0a,
+	0x11, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f,
+	0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0f, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69,
+	0x74, 0x79, 0x46, 0x69, 0x65, 0x6c, 0x64, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x0a, 0x70, 0x72, 0x69,
+	0x6f, 0x72, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x20, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x50, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x52,
+	0x0a, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x3e, 0x0a, 0x1b, 0x70,
+	0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x68, 0x79, 0x73, 0x74, 0x65, 0x72, 0x65, 0x73,
+	0x69, 0x73, 0x5f, 0x70, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03,
+	0x52, 0x19, 0x70, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x48, 0x79, 0x73, 0x74, 0x65, 0x72,
+	0x65, 0x73, 0x69, 0x73, 0x50, 0x65, 0x72, 0x63, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x64,
+	0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x06, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x50, 0x72, 0x65, 0x66,
+	0x69, 0x78, 0x12, 0x2b, 0x0a, 0x11, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x5f, 0x68,
+	0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x6d,
+	0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x48, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x22,
+	0x45, 0x0a, 0x12, 0x4d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x46, 0x69, 0x65, 0x6c, 0x64,
+	0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x69,
+	0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x49, 0x64,
+	0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x6d, 0x0a, 0x10, 0x4d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x50, 0x72, 0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72,
+	0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72,
+	0x69, 0x6f, 0x72, 0x69, 0x74, 0x79, 0x12, 0x3d, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68,
+	0x6f, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x49, 0x6d, 0x70, 0x61, 0x63,
+	0x74, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x09, 0x74, 0x68, 0x72, 0x65,
+	0x73, 0x68, 0x6f, 0x6c, 0x64, 0x22, 0xe7, 0x04, 0x0a, 0x0f, 0x49, 0x6d, 0x70, 0x61, 0x63, 0x74,
+	0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x4f, 0x0a, 0x13, 0x74, 0x65, 0x73,
+	0x74, 0x5f, 0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x68,
+	0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x11, 0x74, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x73, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x12, 0x49, 0x0a, 0x10, 0x74, 0x65,
+	0x73, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x73, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x18, 0x05,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x68, 0x72, 0x65,
+	0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x0e, 0x74, 0x65, 0x73, 0x74, 0x52, 0x75, 0x6e, 0x73, 0x46,
+	0x61, 0x69, 0x6c, 0x65, 0x64, 0x12, 0x53, 0x0a, 0x15, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d,
+	0x69, 0x74, 0x5f, 0x72, 0x75, 0x6e, 0x73, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x18, 0x06,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x68, 0x72, 0x65,
+	0x73, 0x68, 0x6f, 0x6c, 0x64, 0x52, 0x13, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74,
+	0x52, 0x75, 0x6e, 0x73, 0x46, 0x61, 0x69, 0x6c, 0x65, 0x64, 0x12, 0x61, 0x0a, 0x1c, 0x63, 0x72,
+	0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f,
+	0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c,
+	0x64, 0x52, 0x1a, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x46, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x73, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x12, 0x39, 0x0a,
+	0x16, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x66, 0x61, 0x69, 0x6c,
+	0x75, 0x72, 0x65, 0x73, 0x5f, 0x31, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52,
+	0x14, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x46, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x73, 0x31, 0x64, 0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x16, 0x75, 0x6e, 0x65, 0x78,
+	0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f,
+	0x33, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x14, 0x75, 0x6e, 0x65, 0x78,
+	0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x33, 0x64,
+	0x88, 0x01, 0x01, 0x12, 0x39, 0x0a, 0x16, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65,
+	0x64, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f, 0x37, 0x64, 0x18, 0x03, 0x20,
+	0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x14, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65,
+	0x64, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x37, 0x64, 0x88, 0x01, 0x01, 0x42, 0x19,
+	0x0a, 0x17, 0x5f, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x66, 0x61,
+	0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f, 0x31, 0x64, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x75, 0x6e,
+	0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65,
+	0x73, 0x5f, 0x33, 0x64, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63,
+	0x74, 0x65, 0x64, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f, 0x37, 0x64, 0x22,
+	0x9b, 0x01, 0x0a, 0x0f, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x54, 0x68, 0x72, 0x65, 0x73, 0x68,
+	0x6f, 0x6c, 0x64, 0x12, 0x1c, 0x0a, 0x07, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x61, 0x79, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x03, 0x48, 0x00, 0x52, 0x06, 0x6f, 0x6e, 0x65, 0x44, 0x61, 0x79, 0x88, 0x01,
+	0x01, 0x12, 0x20, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x65, 0x5f, 0x64, 0x61, 0x79, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x03, 0x48, 0x01, 0x52, 0x08, 0x74, 0x68, 0x72, 0x65, 0x65, 0x44, 0x61, 0x79,
+	0x88, 0x01, 0x01, 0x12, 0x20, 0x0a, 0x09, 0x73, 0x65, 0x76, 0x65, 0x6e, 0x5f, 0x64, 0x61, 0x79,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x03, 0x48, 0x02, 0x52, 0x08, 0x73, 0x65, 0x76, 0x65, 0x6e, 0x44,
+	0x61, 0x79, 0x88, 0x01, 0x01, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x61,
+	0x79, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x74, 0x68, 0x72, 0x65, 0x65, 0x5f, 0x64, 0x61, 0x79, 0x42,
+	0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x65, 0x76, 0x65, 0x6e, 0x5f, 0x64, 0x61, 0x79, 0x22, 0x80, 0x01,
+	0x0a, 0x0b, 0x52, 0x65, 0x61, 0x6c, 0x6d, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12, 0x0a,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d,
+	0x65, 0x12, 0x5d, 0x0a, 0x15, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e,
+	0x74, 0x5f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x29, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x41, 0x6e, 0x61,
+	0x6c, 0x79, 0x73, 0x69, 0x73, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x13, 0x74, 0x65, 0x73,
+	0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73,
+	0x22, 0x5c, 0x0a, 0x0a, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x12, 0x4e,
+	0x0a, 0x0f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x5f, 0x72, 0x75, 0x6c, 0x65,
+	0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x4e, 0x61, 0x6d,
+	0x65, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x52,
+	0x0d, 0x74, 0x65, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x6b,
+	0x0a, 0x16, 0x54, 0x65, 0x73, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65,
+	0x72, 0x69, 0x6e, 0x67, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x18, 0x0a, 0x07,
+	0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70,
+	0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x6c, 0x69, 0x6b, 0x65, 0x5f, 0x74,
+	0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6c,
+	0x69, 0x6b, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x42, 0x2f, 0x5a, 0x2d, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x3b, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescData = file_infra_appengine_weetbix_proto_config_project_config_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_config_project_config_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_infra_appengine_weetbix_proto_config_project_config_proto_goTypes = []interface{}{
+	(*ProjectConfig)(nil),             // 0: weetbix.config.ProjectConfig
+	(*ProjectMetadata)(nil),           // 1: weetbix.config.ProjectMetadata
+	(*MonorailProject)(nil),           // 2: weetbix.config.MonorailProject
+	(*MonorailFieldValue)(nil),        // 3: weetbix.config.MonorailFieldValue
+	(*MonorailPriority)(nil),          // 4: weetbix.config.MonorailPriority
+	(*ImpactThreshold)(nil),           // 5: weetbix.config.ImpactThreshold
+	(*MetricThreshold)(nil),           // 6: weetbix.config.MetricThreshold
+	(*RealmConfig)(nil),               // 7: weetbix.config.RealmConfig
+	(*Clustering)(nil),                // 8: weetbix.config.Clustering
+	(*TestNameClusteringRule)(nil),    // 9: weetbix.config.TestNameClusteringRule
+	(*timestamppb.Timestamp)(nil),     // 10: google.protobuf.Timestamp
+	(*TestVariantAnalysisConfig)(nil), // 11: weetbix.config.TestVariantAnalysisConfig
+}
+var file_infra_appengine_weetbix_proto_config_project_config_proto_depIdxs = []int32{
+	1,  // 0: weetbix.config.ProjectConfig.project_metadata:type_name -> weetbix.config.ProjectMetadata
+	2,  // 1: weetbix.config.ProjectConfig.monorail:type_name -> weetbix.config.MonorailProject
+	5,  // 2: weetbix.config.ProjectConfig.bug_filing_threshold:type_name -> weetbix.config.ImpactThreshold
+	7,  // 3: weetbix.config.ProjectConfig.realms:type_name -> weetbix.config.RealmConfig
+	10, // 4: weetbix.config.ProjectConfig.last_updated:type_name -> google.protobuf.Timestamp
+	8,  // 5: weetbix.config.ProjectConfig.clustering:type_name -> weetbix.config.Clustering
+	3,  // 6: weetbix.config.MonorailProject.default_field_values:type_name -> weetbix.config.MonorailFieldValue
+	4,  // 7: weetbix.config.MonorailProject.priorities:type_name -> weetbix.config.MonorailPriority
+	5,  // 8: weetbix.config.MonorailPriority.threshold:type_name -> weetbix.config.ImpactThreshold
+	6,  // 9: weetbix.config.ImpactThreshold.test_results_failed:type_name -> weetbix.config.MetricThreshold
+	6,  // 10: weetbix.config.ImpactThreshold.test_runs_failed:type_name -> weetbix.config.MetricThreshold
+	6,  // 11: weetbix.config.ImpactThreshold.presubmit_runs_failed:type_name -> weetbix.config.MetricThreshold
+	6,  // 12: weetbix.config.ImpactThreshold.critical_failures_exonerated:type_name -> weetbix.config.MetricThreshold
+	11, // 13: weetbix.config.RealmConfig.test_variant_analysis:type_name -> weetbix.config.TestVariantAnalysisConfig
+	9,  // 14: weetbix.config.Clustering.test_name_rules:type_name -> weetbix.config.TestNameClusteringRule
+	15, // [15:15] is the sub-list for method output_type
+	15, // [15:15] is the sub-list for method input_type
+	15, // [15:15] is the sub-list for extension type_name
+	15, // [15:15] is the sub-list for extension extendee
+	0,  // [0:15] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_config_project_config_proto_init() }
+func file_infra_appengine_weetbix_proto_config_project_config_proto_init() {
+	if File_infra_appengine_weetbix_proto_config_project_config_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectMetadata); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MonorailProject); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MonorailFieldValue); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MonorailPriority); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ImpactThreshold); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*MetricThreshold); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*RealmConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Clustering); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestNameClusteringRule); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[5].OneofWrappers = []interface{}{}
+	file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes[6].OneofWrappers = []interface{}{}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_config_project_config_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   10,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_config_project_config_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_config_project_config_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_config_project_config_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_config_project_config_proto = out.File
+	file_infra_appengine_weetbix_proto_config_project_config_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_config_project_config_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_config_project_config_proto_depIdxs = nil
+}
diff --git a/analysis/proto/config/project_config.proto b/analysis/proto/config/project_config.proto
new file mode 100644
index 0000000..34a2e79
--- /dev/null
+++ b/analysis/proto/config/project_config.proto
@@ -0,0 +1,335 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.config;
+
+import "google/protobuf/timestamp.proto";
+
+import "go.chromium.org/luci/analysis/proto/config/test_variant_analysis_config.proto";
+
+
+option go_package = "go.chromium.org/luci/analysis/proto/config;configpb";
+
+// ProjectConfig is the project-specific configuration data for Weetbix.
+message ProjectConfig {
+
+  // The project metadata (eg. display name).
+  ProjectMetadata project_metadata = 6;
+
+  // The monorail configuration to use when filing bugs.
+  MonorailProject monorail = 1;
+
+  // The threshold at which to file bugs.
+  // If reason cluster's impact exceeds the given threshold,
+  // a bug will be filed for it.
+  // Alternatively, if test name cluster's impact exceeds 134% of the given
+  // threshold, a bug will also be filed for it.
+  //
+  // Weetbix's bias towards reason clusters reflects the fact that bugs
+  // filed for reasons should be better scoped and more actionable
+  // (focus on one problem).
+  ImpactThreshold bug_filing_threshold = 2;
+
+  // Per realm configurations.
+  repeated RealmConfig realms = 3;
+
+  // The last time this project configuration was updated.
+  // Weetbix sets and stores this value internally. Do not set
+  // in your project's configuration file, it will be ignored.
+  google.protobuf.Timestamp last_updated = 4;
+
+  // Configuration for how to cluster test results.
+  Clustering clustering = 5;
+}
+
+
+// ProjectMetadata provides data about the project that are mostly used in ui.
+message ProjectMetadata {
+
+  // Indicates the preferred display name for the project in the UI.
+  // Deprecated: not used anymore.
+  string display_name = 1;
+}
+
+// MonorailProject describes the configuration to use when filing bugs
+// into a given monorail project.
+message MonorailProject {
+  // The monorail project being described.
+  // E.g. "chromium".
+  string project = 1;
+
+  // The field values to use when creating new bugs.
+  // For example, on chromium issue tracker, there is a manadatory
+  // issue type field (field 10), which must be set to "Bug".
+  repeated MonorailFieldValue default_field_values = 2;
+
+  // The ID of the issue's priority field. You can find this by visiting
+  // https://monorail-prod.appspot.com/p/<project>/adminLabels, scrolling
+  // down to Custom fields and finding the ID of the field you wish to set.
+  int64 priority_field_id = 3;
+
+  // The possible bug priorities and their associated impact thresholds.
+  // Priorities must be listed from highest (i.e. P0) to lowest (i.e. P3).
+  // Higher priorities can only be reached if the thresholds for all lower
+  // priorities are also met.
+  // The impact thresholds for setting the lowest priority implicitly
+  // identifies the bug closure threshold -- if no priority can be
+  // matched, the bug is closed. Satisfying the threshold for filing bugs MUST
+  // at least imply the threshold for the lowest priority, and MAY imply
+  // the thresholds of higher priorities.
+  repeated MonorailPriority priorities = 4;
+
+  // Controls the amount of hysteresis used in setting bug priorities.
+  // Once a bug is assigned a given priority, its priority will only be
+  // increased if it exceeds the next priority's thresholds by the
+  // specified percentage margin, and decreased if the current priority's
+  // thresholds exceed the bug's impact by the given percentage margin.
+  //
+  // A value of 100 indicates impact may be double the threshold for
+  // the next highest priority value, (or half the threshold of the
+  // current priority value,) before a bug's priority is increased
+  // (or decreased).
+  //
+  // Valid values are from 0 (no hystersis) to 1,000 (10x hysteresis).
+  int64 priority_hysteresis_percent = 5;
+
+  // The prefix that should appear when displaying bugs from the
+  // given bug tracking system. E.g. "crbug.com" or "fxbug.dev".
+  // If no prefix is specified, only the bug number will appear.
+  // Otherwise, the supplifed prefix will appear, followed by a
+  // forward slash ("/"), followed by the bug number.
+  // Valid prefixes match `^[a-z0-9\-.]{0,64}$`.
+  string display_prefix = 6;
+
+  // The preferred hostname to use in links to monorail. For example,
+  // "bugs.chromium.org" or "bugs.fuchsia.dev".
+  string monorail_hostname = 7;
+}
+
+// MonorailFieldValue describes a monorail field/value pair.
+message MonorailFieldValue {
+  // The ID of the field to set. You can find this by visiting
+  // https://monorail-prod.appspot.com/p/<project>/adminLabels, scrolling
+  // down to Custom fields and finding the ID of the field you wish to set.
+  int64 field_id = 1;
+
+  // The field value. Values are encoded according to the field type:
+  // - Enumeration types: the string enumeration value (e.g. "Bug").
+  // - Integer types: the integer, converted to a string (e.g. "1052").
+  // - String types: the value, included verbatim.
+  // - User types: the user's resource name (e.g. "users/2627516260").
+  //   User IDs can be identified by looking at the people listing for a
+  //   project:  https://monorail-prod.appspot.com/p/<project>/people/list.
+  //   The User ID is included in the URL as u=<number> when clicking into
+  //   the page for a particular user. For example, "user/3816576959" is
+  //   https://monorail-prod.appspot.com/p/chromium/people/detail?u=3816576959.
+  // - Date types: the number of seconds since epoch, as a string
+  //   (e.g. "1609459200" for 1 January 2021).
+  // - URL type: the URL value, as a string (e.g. "https://www.google.com/").
+  //
+  // The source of truth for mapping of field types to values is as
+  // defined in the Monorail v3 API, found here:
+  // https://source.chromium.org/chromium/infra/infra/+/main:appengine/monorail/api/v3/api_proto/issue_objects.proto?q=%22message%20FieldValue%22
+  string value = 2;
+}
+
+// MonorailPriority represents configuration for when to use a given
+// priority value in a bug.
+message MonorailPriority {
+  // The monorail priority value. For example, "0". This depends on the
+  // valid priority field values you have defined in your monorail project.
+  string priority = 1;
+
+  // The threshold at which to apply the priority.
+  ImpactThreshold threshold = 2;
+}
+
+// ImpactThreshold specifies a condition on a cluster's impact metrics.
+// The threshold is considered satisfied if any of the individual metric
+// thresholds is met or exceeded (i.e. if multiple thresholds are set, they
+// are combined using an OR-semantic). If no threshold is set on any individual
+// metric, the threshold as a whole is unsatisfiable.
+message ImpactThreshold {
+  // The number of test results that were unexpected failures.
+  MetricThreshold test_results_failed = 4;
+
+  // The number of test runs that failed.
+  // A test run (also known as a 'shard' (chromium) or 'task' (Chrome OS))
+  // is considered failed if all tries of test(s) in it unexpectedly failed.
+  // The failed test run is attributed to the last failure of each of the
+  // test(s) that failed on all tries.
+  MetricThreshold test_runs_failed = 5;
+
+  // The number of presubmit runs that failed.
+  MetricThreshold presubmit_runs_failed = 6;
+
+  // The number of test failures on critical builders that were exonerated,
+  // with an exoneration reason other than NOT_CRITICAL.
+  MetricThreshold critical_failures_exonerated = 7;
+
+  // Deprecated. No longer has any effect. Retained for textproto
+  // compatibility only.
+  optional int64 unexpected_failures_1d = 1;
+
+  // Deprecated. No longer has any effect. Retained for textproto
+  // compatibility only.
+  optional int64 unexpected_failures_3d = 2;
+
+  // Deprecated. No longer has any effect. Retained for textproto
+  // compatibility only.
+  optional int64 unexpected_failures_7d = 3;
+}
+
+// MetricThreshold specifies thresholds for a particular metric.
+// The threshold is considered satisfied if any of the individual metric
+// thresholds is met or exceeded (i.e. if multiple thresholds are set, they
+// are combined using an OR-semantic). If no threshold is set, the threshold
+// as a whole is unsatisfiable.
+message MetricThreshold {
+  // The threshold for one day.
+  optional int64 one_day = 1;
+
+  // The threshold for three day.
+  optional int64 three_day = 2;
+
+  // The threshold for seven days.
+  optional int64 seven_day = 3;
+}
+
+// Configurations per realm.
+message RealmConfig {
+  // Name of the realm.
+  //
+  // Must match `^[a-z0-9_\.\-/]{1,400}$`.
+  // Must not contain the project part. I.e. for "chromium:ci" realm the value
+  // here must be "ci".
+  string name = 1;
+
+  // Test variant analysis configurations for the realm.
+  TestVariantAnalysisConfig test_variant_analysis = 2;
+}
+
+// Configuration for how test results are clustered.
+message Clustering {
+  // Rules used to cluster test results by test name.
+  // The order of rules matters; the first matching rule will be used
+  // to cluster a given test result.
+  //
+  // If no rule matches, the test results will be clustered on the
+  // full test name. This corresponds approximately to the rule:
+  // {
+  //   name: "Full test name"
+  //   pattern: "^(?P<testname>.*)$"
+  //   like_template: "${testname}"
+  // }
+  repeated TestNameClusteringRule test_name_rules = 1;
+}
+
+// A rule used to cluster a test result by test name.
+message TestNameClusteringRule {
+  // A human-readable name for the rule. This should be unique for each rule.
+  // This may be used by Weetbix to explain why it chose to cluster the test
+  // name in this way.
+  string name = 1;
+
+  // The regular expression describing which test names should be clustered
+  // by this rule.
+  //
+  // Example.
+  //   Assume our project uploads google test (gtest) results with the test
+  //   name prefix "gtest://".
+  //   If want to cluster value-parameterized google tests
+  //   together based on the test suite and test case name (ignoring
+  //   the value parameter), we may use a pattern like:
+  //     "^gtest://(\w+/)?(?P<testcase>\w+\.\w+)/\w+$"
+  //
+  //   This will allow us to cluster test names like:
+  //     "gtest://InstantiationOne/ColorSpaceTest.testNullTransform/0"
+  //     "gtest://InstantiationOne/ColorSpaceTest.testNullTransform/1"
+  //     "gtest://InstantiationTwo/ColorSpaceTest.testNullTransform/0"
+  //   together.
+  //
+  //   See https://github.com/google/googletest/blob/main/docs/advanced.md#how-to-write-value-parameterized-tests
+  //   to understand value-parameterised google tests.
+  //
+  // Use ?P<name> to name capture groups, so their values can be used in
+  // like_template below.
+  string pattern = 2;
+
+  // The template used to generate a LIKE expression on test names
+  // that defines the test name cluster identified by this rule.
+  //
+  // This like expression has two purposes:
+  // (1) If the test name cluster is large enough to justify the
+  //     creation of a bug cluster, the like expression is used to
+  //     generate a failure association rule of the following form:
+  //        test LIKE "<evaluated like_template>"
+  // (2) A hash of the expression is used as the clustering key for the
+  //     test name-based suggested cluster. This generally has the desired
+  //     clustering behaviour, i.e. the parts of the test name which
+  //     are important enough to included in the LIKE expression for (1)
+  //     are also those on which clustering should occur.
+  //
+  // As is usual for LIKE expressions, the template can contain
+  // the following operators to do wildcard matching:
+  // * '%' for wildcard match of an arbitrary number of characters, and
+  // * '_' for single character wildcard match.
+  //
+  // To match literal '%' or '_', escape the operator with a '\',
+  // i.e. use "\%" or "\_" to match literal '%' and '_' respectively.
+  // To match literal '\', you should use "\\".
+  //
+  // The template can refer to parts of the test name matched by
+  // the rule pattern using ${name}, where name refers to the capture
+  // group (see pattern). To insert the literal '$', the sequence '$$'
+  // should be used.
+  //
+  // Example.
+  //   Assume our project uploads google test (gtest) results with the test
+  //   name prefix "gtest://". Further assume we used the pattern:
+  //     "^gtest://(\w+/)?(?P<testcase>\w+\.\w+)/\w+$"
+  //
+  //   We might use the following like_template:
+  //     "gtest://%${testcase}%"
+  //
+  //   When instantiated for a value-parameterised test, e.g.
+  //   "gtest://InstantiationOne/ColorSpaceTest.testNullTransform/0",
+  //   the result would be a failure association rule like:
+  //     test LIKE "gtest://%ColorSpaceTest.testNullTransform%"
+  //
+  //   Note the use of ${testcase} to refer to the testname capture group
+  //   specified in the pattern example.
+  //
+  //   See https://github.com/google/googletest/blob/main/docs/advanced.md#how-to-write-value-parameterized-tests
+  //   to understand value-parameterised google tests.
+  //
+  // It is known that not all clusters can be precisely matched by
+  // a LIKE expression. Nonetheless, Weetbix prefers LIKE expressions
+  // as they are easier to comprehend and modify by users, and in
+  // most cases, the added precision is not required.
+  //
+  // As such, your rule should try to ensure the generated LIKE statement
+  // captures your clustering logic as best it can. Your LIKE expression
+  // MUST match all test names matched by your regex pattern, and MAY
+  // capture additional test names (though this is preferably minimised,
+  // to reduce differences between the suggested clusters and eventual
+  // bug clusters).
+  //
+  // Weetbix will automatically escape any '%' '_' and '\' in parts of
+  // the matched test name before substitution to ensure captured parts
+  // of the test name are matched literally and not interpreted.
+  string like_template = 3;
+}
\ No newline at end of file
diff --git a/analysis/proto/config/test_variant_analysis_config.pb.go b/analysis/proto/config/test_variant_analysis_config.pb.go
new file mode 100644
index 0000000..085a371
--- /dev/null
+++ b/analysis/proto/config/test_variant_analysis_config.pb.go
@@ -0,0 +1,456 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/config/test_variant_analysis_config.proto
+
+package configpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	durationpb "google.golang.org/protobuf/types/known/durationpb"
+	analyzedtestvariant "go.chromium.org/luci/analysis/proto/analyzedtestvariant"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Configurations for BigQuery export.
+type BigQueryExport struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The BigQuery table the analyzed test variants should be exported to.
+	//
+	// Weetbix will use the project-scoped service account when exporting the
+	// data.
+	// The project-scoped service account MUST have permissions to create the
+	// table in the dataset and insert rows to the table, e.g. WRITER role.
+	Table *BigQueryExport_BigQueryTable `protobuf:"bytes,1,opt,name=table,proto3" json:"table,omitempty"`
+	// Use predicate to query test variants that should be exported to
+	// BigQuery table.
+	Predicate *analyzedtestvariant.Predicate `protobuf:"bytes,2,opt,name=predicate,proto3" json:"predicate,omitempty"`
+}
+
+func (x *BigQueryExport) Reset() {
+	*x = BigQueryExport{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BigQueryExport) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BigQueryExport) ProtoMessage() {}
+
+func (x *BigQueryExport) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BigQueryExport.ProtoReflect.Descriptor instead.
+func (*BigQueryExport) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *BigQueryExport) GetTable() *BigQueryExport_BigQueryTable {
+	if x != nil {
+		return x.Table
+	}
+	return nil
+}
+
+func (x *BigQueryExport) GetPredicate() *analyzedtestvariant.Predicate {
+	if x != nil {
+		return x.Predicate
+	}
+	return nil
+}
+
+// Configurations for UpdateTestVariant task.
+type UpdateTestVariantTask struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A task will run every interval to calculate the test variant's status.
+	UpdateTestVariantTaskInterval *durationpb.Duration `protobuf:"bytes,1,opt,name=update_test_variant_task_interval,json=updateTestVariantTaskInterval,proto3" json:"update_test_variant_task_interval,omitempty"`
+	// In each task, verdicts within the duration will be queried
+	// and used to calculate the test variant's status.
+	//
+	// For example, if the duration is 24 hours, Weetbix will use all the verdicts
+	// from the last 24 hours to calculate the variant's status.
+	TestVariantStatusUpdateDuration *durationpb.Duration `protobuf:"bytes,2,opt,name=test_variant_status_update_duration,json=testVariantStatusUpdateDuration,proto3" json:"test_variant_status_update_duration,omitempty"`
+}
+
+func (x *UpdateTestVariantTask) Reset() {
+	*x = UpdateTestVariantTask{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UpdateTestVariantTask) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateTestVariantTask) ProtoMessage() {}
+
+func (x *UpdateTestVariantTask) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateTestVariantTask.ProtoReflect.Descriptor instead.
+func (*UpdateTestVariantTask) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *UpdateTestVariantTask) GetUpdateTestVariantTaskInterval() *durationpb.Duration {
+	if x != nil {
+		return x.UpdateTestVariantTaskInterval
+	}
+	return nil
+}
+
+func (x *UpdateTestVariantTask) GetTestVariantStatusUpdateDuration() *durationpb.Duration {
+	if x != nil {
+		return x.TestVariantStatusUpdateDuration
+	}
+	return nil
+}
+
+type TestVariantAnalysisConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Configurations for UpdateTestVariant task.
+	UpdateTestVariantTask *UpdateTestVariantTask `protobuf:"bytes,1,opt,name=update_test_variant_task,json=updateTestVariantTask,proto3" json:"update_test_variant_task,omitempty"`
+	// Configurations for BigQuery export.
+	BqExports []*BigQueryExport `protobuf:"bytes,2,rep,name=bq_exports,json=bqExports,proto3" json:"bq_exports,omitempty"`
+}
+
+func (x *TestVariantAnalysisConfig) Reset() {
+	*x = TestVariantAnalysisConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantAnalysisConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantAnalysisConfig) ProtoMessage() {}
+
+func (x *TestVariantAnalysisConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantAnalysisConfig.ProtoReflect.Descriptor instead.
+func (*TestVariantAnalysisConfig) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *TestVariantAnalysisConfig) GetUpdateTestVariantTask() *UpdateTestVariantTask {
+	if x != nil {
+		return x.UpdateTestVariantTask
+	}
+	return nil
+}
+
+func (x *TestVariantAnalysisConfig) GetBqExports() []*BigQueryExport {
+	if x != nil {
+		return x.BqExports
+	}
+	return nil
+}
+
+type BigQueryExport_BigQueryTable struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	CloudProject string `protobuf:"bytes,1,opt,name=cloud_project,json=cloudProject,proto3" json:"cloud_project,omitempty"`
+	Dataset      string `protobuf:"bytes,2,opt,name=dataset,proto3" json:"dataset,omitempty"`
+	Table        string `protobuf:"bytes,3,opt,name=table,proto3" json:"table,omitempty"`
+}
+
+func (x *BigQueryExport_BigQueryTable) Reset() {
+	*x = BigQueryExport_BigQueryTable{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BigQueryExport_BigQueryTable) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BigQueryExport_BigQueryTable) ProtoMessage() {}
+
+func (x *BigQueryExport_BigQueryTable) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BigQueryExport_BigQueryTable.ProtoReflect.Descriptor instead.
+func (*BigQueryExport_BigQueryTable) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescGZIP(), []int{0, 0}
+}
+
+func (x *BigQueryExport_BigQueryTable) GetCloudProject() string {
+	if x != nil {
+		return x.CloudProject
+	}
+	return ""
+}
+
+func (x *BigQueryExport_BigQueryTable) GetDataset() string {
+	if x != nil {
+		return x.Dataset
+	}
+	return ""
+}
+
+func (x *BigQueryExport_BigQueryTable) GetTable() string {
+	if x != nil {
+		return x.Table
+	}
+	return ""
+}
+
+var File_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDesc = []byte{
+	0x0a, 0x47, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x5f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x5f, 0x63, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+	0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74,
+	0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x41, 0x69, 0x6e, 0x66, 0x72, 0x61,
+	0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65,
+	0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x65,
+	0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x80, 0x02, 0x0a,
+	0x0e, 0x42, 0x69, 0x67, 0x51, 0x75, 0x65, 0x72, 0x79, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x12,
+	0x42, 0x0a, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2c,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
+	0x42, 0x69, 0x67, 0x51, 0x75, 0x65, 0x72, 0x79, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x2e, 0x42,
+	0x69, 0x67, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x52, 0x05, 0x74, 0x61,
+	0x62, 0x6c, 0x65, 0x12, 0x44, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x7a, 0x65, 0x64, 0x74, 0x65, 0x73, 0x74, 0x76, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x09,
+	0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x1a, 0x64, 0x0a, 0x0d, 0x42, 0x69, 0x67,
+	0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x61, 0x62, 0x6c, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x6c,
+	0x6f, 0x75, 0x64, 0x5f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0c, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12,
+	0x18, 0x0a, 0x07, 0x64, 0x61, 0x74, 0x61, 0x73, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x07, 0x64, 0x61, 0x74, 0x61, 0x73, 0x65, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x61, 0x62,
+	0x6c, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x61, 0x62, 0x6c, 0x65, 0x22,
+	0xe5, 0x01, 0x0a, 0x15, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x63, 0x0a, 0x21, 0x75, 0x70, 0x64,
+	0x61, 0x74, 0x65, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x5f, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52,
+	0x1d, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61,
+	0x6e, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x67,
+	0x0a, 0x23, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x73,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x64, 0x75, 0x72,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75,
+	0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x1f, 0x74, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x44,
+	0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xba, 0x01, 0x0a, 0x19, 0x54, 0x65, 0x73, 0x74,
+	0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x43,
+	0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x5e, 0x0a, 0x18, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f,
+	0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x74, 0x61, 0x73,
+	0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54,
+	0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x15,
+	0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e,
+	0x74, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x3d, 0x0a, 0x0a, 0x62, 0x71, 0x5f, 0x65, 0x78, 0x70, 0x6f,
+	0x72, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x42, 0x69, 0x67, 0x51, 0x75,
+	0x65, 0x72, 0x79, 0x45, 0x78, 0x70, 0x6f, 0x72, 0x74, 0x52, 0x09, 0x62, 0x71, 0x45, 0x78, 0x70,
+	0x6f, 0x72, 0x74, 0x73, 0x42, 0x2f, 0x5a, 0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70,
+	0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x3b, 0x63, 0x6f, 0x6e,
+	0x66, 0x69, 0x67, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescData = file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes = make([]protoimpl.MessageInfo, 4)
+var file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_goTypes = []interface{}{
+	(*BigQueryExport)(nil),                // 0: weetbix.config.BigQueryExport
+	(*UpdateTestVariantTask)(nil),         // 1: weetbix.config.UpdateTestVariantTask
+	(*TestVariantAnalysisConfig)(nil),     // 2: weetbix.config.TestVariantAnalysisConfig
+	(*BigQueryExport_BigQueryTable)(nil),  // 3: weetbix.config.BigQueryExport.BigQueryTable
+	(*analyzedtestvariant.Predicate)(nil), // 4: weetbix.analyzedtestvariant.Predicate
+	(*durationpb.Duration)(nil),           // 5: google.protobuf.Duration
+}
+var file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_depIdxs = []int32{
+	3, // 0: weetbix.config.BigQueryExport.table:type_name -> weetbix.config.BigQueryExport.BigQueryTable
+	4, // 1: weetbix.config.BigQueryExport.predicate:type_name -> weetbix.analyzedtestvariant.Predicate
+	5, // 2: weetbix.config.UpdateTestVariantTask.update_test_variant_task_interval:type_name -> google.protobuf.Duration
+	5, // 3: weetbix.config.UpdateTestVariantTask.test_variant_status_update_duration:type_name -> google.protobuf.Duration
+	1, // 4: weetbix.config.TestVariantAnalysisConfig.update_test_variant_task:type_name -> weetbix.config.UpdateTestVariantTask
+	0, // 5: weetbix.config.TestVariantAnalysisConfig.bq_exports:type_name -> weetbix.config.BigQueryExport
+	6, // [6:6] is the sub-list for method output_type
+	6, // [6:6] is the sub-list for method input_type
+	6, // [6:6] is the sub-list for extension type_name
+	6, // [6:6] is the sub-list for extension extendee
+	0, // [0:6] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_init() }
+func file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_init() {
+	if File_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BigQueryExport); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UpdateTestVariantTask); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantAnalysisConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BigQueryExport_BigQueryTable); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   4,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto = out.File
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_config_test_variant_analysis_config_proto_depIdxs = nil
+}
diff --git a/analysis/proto/config/test_variant_analysis_config.proto b/analysis/proto/config/test_variant_analysis_config.proto
new file mode 100644
index 0000000..eac7ecf
--- /dev/null
+++ b/analysis/proto/config/test_variant_analysis_config.proto
@@ -0,0 +1,64 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.config;
+
+import "google/protobuf/duration.proto";
+import "go.chromium.org/luci/analysis/proto/analyzedtestvariant/predicate.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/config;configpb";
+
+// Configurations for BigQuery export.
+message BigQueryExport {
+  message BigQueryTable {
+    string cloud_project = 1;
+    string dataset = 2;
+    string table = 3;
+  }
+  // The BigQuery table the analyzed test variants should be exported to.
+  //
+  // Weetbix will use the project-scoped service account when exporting the
+  // data.
+  // The project-scoped service account MUST have permissions to create the
+  // table in the dataset and insert rows to the table, e.g. WRITER role.
+  BigQueryTable table = 1;
+
+  // Use predicate to query test variants that should be exported to
+  // BigQuery table.
+  weetbix.analyzedtestvariant.Predicate predicate = 2;
+}
+
+// Configurations for UpdateTestVariant task.
+message UpdateTestVariantTask {
+  // A task will run every interval to calculate the test variant's status.
+  google.protobuf.Duration update_test_variant_task_interval = 1;
+
+  // In each task, verdicts within the duration will be queried
+  // and used to calculate the test variant's status.
+  //
+  // For example, if the duration is 24 hours, Weetbix will use all the verdicts
+  // from the last 24 hours to calculate the variant's status.
+  google.protobuf.Duration test_variant_status_update_duration = 2;
+}
+
+message TestVariantAnalysisConfig {
+  // Configurations for UpdateTestVariant task.
+  UpdateTestVariantTask update_test_variant_task = 1;
+
+  // Configurations for BigQuery export.
+  repeated BigQueryExport bq_exports = 2;
+
+}
\ No newline at end of file
diff --git a/analysis/proto/v1/changelist.pb.go b/analysis/proto/v1/changelist.pb.go
new file mode 100644
index 0000000..f6599f0
--- /dev/null
+++ b/analysis/proto/v1/changelist.pb.go
@@ -0,0 +1,183 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/changelist.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// A changelist. Currently represents only Gerrit Patchsets.
+type Changelist struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Gerrit hostname, e.g. "chromium-review.googlesource.com".
+	Host string `protobuf:"bytes,1,opt,name=host,proto3" json:"host,omitempty"`
+	// Change number, e.g. 12345.
+	Change int64 `protobuf:"varint,2,opt,name=change,proto3" json:"change,omitempty"`
+	// Patch set number, e.g. 1.
+	Patchset int32 `protobuf:"varint,3,opt,name=patchset,proto3" json:"patchset,omitempty"`
+}
+
+func (x *Changelist) Reset() {
+	*x = Changelist{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_changelist_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Changelist) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Changelist) ProtoMessage() {}
+
+func (x *Changelist) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_changelist_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Changelist.ProtoReflect.Descriptor instead.
+func (*Changelist) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Changelist) GetHost() string {
+	if x != nil {
+		return x.Host
+	}
+	return ""
+}
+
+func (x *Changelist) GetChange() int64 {
+	if x != nil {
+		return x.Change
+	}
+	return 0
+}
+
+func (x *Changelist) GetPatchset() int32 {
+	if x != nil {
+		return x.Patchset
+	}
+	return 0
+}
+
+var File_infra_appengine_weetbix_proto_v1_changelist_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDesc = []byte{
+	0x0a, 0x31, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x22,
+	0x54, 0x0a, 0x0a, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x12, 0x12, 0x0a,
+	0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73,
+	0x74, 0x12, 0x16, 0x0a, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x03, 0x52, 0x06, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x74,
+	0x63, 0x68, 0x73, 0x65, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x74,
+	0x63, 0x68, 0x73, 0x65, 0x74, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61,
+	0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_changelist_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_proto_v1_changelist_proto_goTypes = []interface{}{
+	(*Changelist)(nil), // 0: weetbix.v1.Changelist
+}
+var file_infra_appengine_weetbix_proto_v1_changelist_proto_depIdxs = []int32{
+	0, // [0:0] is the sub-list for method output_type
+	0, // [0:0] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_changelist_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_changelist_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_changelist_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_changelist_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Changelist); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_changelist_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_changelist_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_changelist_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_changelist_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_depIdxs = nil
+}
diff --git a/analysis/proto/v1/changelist.proto b/analysis/proto/v1/changelist.proto
new file mode 100644
index 0000000..321456e
--- /dev/null
+++ b/analysis/proto/v1/changelist.proto
@@ -0,0 +1,31 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// A changelist. Currently represents only Gerrit Patchsets.
+message Changelist {
+  // Gerrit hostname, e.g. "chromium-review.googlesource.com".
+  string host = 1;
+
+  // Change number, e.g. 12345.
+  int64 change = 2;
+
+  // Patch set number, e.g. 1.
+  int32 patchset = 3;
+}
diff --git a/analysis/proto/v1/clusters.pb.go b/analysis/proto/v1/clusters.pb.go
new file mode 100644
index 0000000..c347be1
--- /dev/null
+++ b/analysis/proto/v1/clusters.pb.go
@@ -0,0 +1,2596 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/clusters.proto
+
+package weetbixpb
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type ClusterRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The LUCI Project for which the test result should be clustered.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// The test results to cluster. At most 1000 test results may be
+	// clustered in one request.
+	TestResults []*ClusterRequest_TestResult `protobuf:"bytes,2,rep,name=test_results,json=testResults,proto3" json:"test_results,omitempty"`
+}
+
+func (x *ClusterRequest) Reset() {
+	*x = ClusterRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterRequest) ProtoMessage() {}
+
+func (x *ClusterRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterRequest.ProtoReflect.Descriptor instead.
+func (*ClusterRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *ClusterRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *ClusterRequest) GetTestResults() []*ClusterRequest_TestResult {
+	if x != nil {
+		return x.TestResults
+	}
+	return nil
+}
+
+type ClusterResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The clusters each test result is in.
+	// Contains one result for each test result specified in the request.
+	// Results are provided in the same order as the request, so
+	// the i-th ClusteredTestResult corresponds to the i-th
+	// TestResult in the request.
+	ClusteredTestResults []*ClusterResponse_ClusteredTestResult `protobuf:"bytes,1,rep,name=clustered_test_results,json=clusteredTestResults,proto3" json:"clustered_test_results,omitempty"`
+	// The versions of clustering algorithms, rules and project configuration
+	// used to service this request. For debugging purposes only.
+	ClusteringVersion *ClusteringVersion `protobuf:"bytes,2,opt,name=clustering_version,json=clusteringVersion,proto3" json:"clustering_version,omitempty"`
+}
+
+func (x *ClusterResponse) Reset() {
+	*x = ClusterResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterResponse) ProtoMessage() {}
+
+func (x *ClusterResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterResponse.ProtoReflect.Descriptor instead.
+func (*ClusterResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ClusterResponse) GetClusteredTestResults() []*ClusterResponse_ClusteredTestResult {
+	if x != nil {
+		return x.ClusteredTestResults
+	}
+	return nil
+}
+
+func (x *ClusterResponse) GetClusteringVersion() *ClusteringVersion {
+	if x != nil {
+		return x.ClusteringVersion
+	}
+	return nil
+}
+
+// The versions of algorithms, rules and configuration used by Weetbix
+// to cluster test results. For a given test result and ClusteringVersion,
+// the set of returned clusters should always be the same.
+type ClusteringVersion struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The version of clustering algorithms used.
+	AlgorithmsVersion int32 `protobuf:"varint,1,opt,name=algorithms_version,json=algorithmsVersion,proto3" json:"algorithms_version,omitempty"`
+	// The version of failure association rules used. This is the Spanner
+	// commit timestamp of the last rule modification incorporated in the
+	// set of rules used to cluster the results.
+	RulesVersion *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=rules_version,json=rulesVersion,proto3" json:"rules_version,omitempty"`
+	// The version of project configuration used. This is the timestamp
+	// the project configuration was ingested by Weetbix.
+	ConfigVersion *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=config_version,json=configVersion,proto3" json:"config_version,omitempty"`
+}
+
+func (x *ClusteringVersion) Reset() {
+	*x = ClusteringVersion{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusteringVersion) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusteringVersion) ProtoMessage() {}
+
+func (x *ClusteringVersion) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusteringVersion.ProtoReflect.Descriptor instead.
+func (*ClusteringVersion) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *ClusteringVersion) GetAlgorithmsVersion() int32 {
+	if x != nil {
+		return x.AlgorithmsVersion
+	}
+	return 0
+}
+
+func (x *ClusteringVersion) GetRulesVersion() *timestamppb.Timestamp {
+	if x != nil {
+		return x.RulesVersion
+	}
+	return nil
+}
+
+func (x *ClusteringVersion) GetConfigVersion() *timestamppb.Timestamp {
+	if x != nil {
+		return x.ConfigVersion
+	}
+	return nil
+}
+
+type BatchGetClustersRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The LUCI project shared by all clusters to retrieve.
+	// Required.
+	// Format: projects/{project}.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The resource name of the clusters retrieve.
+	// Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}.
+	// Designed to conform to aip.dev/231.
+	// At most 1,000 clusters may be requested at a time.
+	Names []string `protobuf:"bytes,2,rep,name=names,proto3" json:"names,omitempty"`
+}
+
+func (x *BatchGetClustersRequest) Reset() {
+	*x = BatchGetClustersRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetClustersRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetClustersRequest) ProtoMessage() {}
+
+func (x *BatchGetClustersRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetClustersRequest.ProtoReflect.Descriptor instead.
+func (*BatchGetClustersRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *BatchGetClustersRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *BatchGetClustersRequest) GetNames() []string {
+	if x != nil {
+		return x.Names
+	}
+	return nil
+}
+
+type BatchGetClustersResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The clusters requested.
+	Clusters []*Cluster `protobuf:"bytes,1,rep,name=clusters,proto3" json:"clusters,omitempty"`
+}
+
+func (x *BatchGetClustersResponse) Reset() {
+	*x = BatchGetClustersResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BatchGetClustersResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BatchGetClustersResponse) ProtoMessage() {}
+
+func (x *BatchGetClustersResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BatchGetClustersResponse.ProtoReflect.Descriptor instead.
+func (*BatchGetClustersResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *BatchGetClustersResponse) GetClusters() []*Cluster {
+	if x != nil {
+		return x.Clusters
+	}
+	return nil
+}
+
+type Cluster struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name of the cluster.
+	// Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Whether there is a recent example in the cluster.
+	HasExample bool `protobuf:"varint,2,opt,name=has_example,json=hasExample,proto3" json:"has_example,omitempty"`
+	// A human-readable name for the cluster.
+	// Only populated for suggested clusters where has_example = true.
+	// Not populated for rule-based clusters.
+	Title string `protobuf:"bytes,3,opt,name=title,proto3" json:"title,omitempty"`
+	// The total number of user changelists which failed presubmit.
+	UserClsFailedPresubmit *Cluster_MetricValues `protobuf:"bytes,4,opt,name=user_cls_failed_presubmit,json=userClsFailedPresubmit,proto3" json:"user_cls_failed_presubmit,omitempty"`
+	// The total number of failures in the cluster that occurred on tryjobs
+	// that were critical (presubmit-blocking) and were exonerated for a
+	// reason other than NOT_CRITICAL or UNEXPECTED_PASS.
+	CriticalFailuresExonerated *Cluster_MetricValues `protobuf:"bytes,5,opt,name=critical_failures_exonerated,json=criticalFailuresExonerated,proto3" json:"critical_failures_exonerated,omitempty"`
+	// The total number of failures in the cluster.
+	Failures *Cluster_MetricValues `protobuf:"bytes,6,opt,name=failures,proto3" json:"failures,omitempty"`
+	// The failure association rule equivalent to the cluster. Populated only
+	// for suggested clusters where has_example = true.
+	// Not populated for rule-based clusters. If you need the failure
+	// association rule for a rule-based cluster, use weetbix.v1.Rules/Get
+	// to retrieve the rule with ID matching the cluster ID.
+	// Used to facilitate creating a new rule based on a suggested cluster.
+	EquivalentFailureAssociationRule string `protobuf:"bytes,7,opt,name=equivalent_failure_association_rule,json=equivalentFailureAssociationRule,proto3" json:"equivalent_failure_association_rule,omitempty"`
+}
+
+func (x *Cluster) Reset() {
+	*x = Cluster{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Cluster) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Cluster) ProtoMessage() {}
+
+func (x *Cluster) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Cluster.ProtoReflect.Descriptor instead.
+func (*Cluster) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *Cluster) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Cluster) GetHasExample() bool {
+	if x != nil {
+		return x.HasExample
+	}
+	return false
+}
+
+func (x *Cluster) GetTitle() string {
+	if x != nil {
+		return x.Title
+	}
+	return ""
+}
+
+func (x *Cluster) GetUserClsFailedPresubmit() *Cluster_MetricValues {
+	if x != nil {
+		return x.UserClsFailedPresubmit
+	}
+	return nil
+}
+
+func (x *Cluster) GetCriticalFailuresExonerated() *Cluster_MetricValues {
+	if x != nil {
+		return x.CriticalFailuresExonerated
+	}
+	return nil
+}
+
+func (x *Cluster) GetFailures() *Cluster_MetricValues {
+	if x != nil {
+		return x.Failures
+	}
+	return nil
+}
+
+func (x *Cluster) GetEquivalentFailureAssociationRule() string {
+	if x != nil {
+		return x.EquivalentFailureAssociationRule
+	}
+	return ""
+}
+
+// Designed to conform with aip.dev/131.
+type GetReclusteringProgressRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the reclustering progress resource to retrieve.
+	// Format: projects/{project}/reclusteringProgress.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetReclusteringProgressRequest) Reset() {
+	*x = GetReclusteringProgressRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetReclusteringProgressRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetReclusteringProgressRequest) ProtoMessage() {}
+
+func (x *GetReclusteringProgressRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetReclusteringProgressRequest.ProtoReflect.Descriptor instead.
+func (*GetReclusteringProgressRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *GetReclusteringProgressRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+// ReclusteringProgress captures the progress re-clustering a
+// given LUCI project's test results using specific rules
+// versions or algorithms versions.
+type ReclusteringProgress struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the reclustering progress resource.
+	// Format: projects/{project}/reclusteringProgress.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// ProgressPerMille is the progress of the current re-clustering run,
+	// measured in thousandths (per mille). As such, this value ranges
+	// from 0 (0% complete) to 1000 (100% complete).
+	ProgressPerMille int32 `protobuf:"varint,2,opt,name=progress_per_mille,json=progressPerMille,proto3" json:"progress_per_mille,omitempty"`
+	// The goal of the last completed re-clustering run.
+	Last *ClusteringVersion `protobuf:"bytes,5,opt,name=last,proto3" json:"last,omitempty"`
+	// The goal of the current re-clustering run. (For which
+	// ProgressPerMille is specified.) This may be the same as the
+	// last completed re-clustering run the available algorithm versions,
+	// rules and configuration is unchanged.
+	Next *ClusteringVersion `protobuf:"bytes,6,opt,name=next,proto3" json:"next,omitempty"`
+}
+
+func (x *ReclusteringProgress) Reset() {
+	*x = ReclusteringProgress{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ReclusteringProgress) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ReclusteringProgress) ProtoMessage() {}
+
+func (x *ReclusteringProgress) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ReclusteringProgress.ProtoReflect.Descriptor instead.
+func (*ReclusteringProgress) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *ReclusteringProgress) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ReclusteringProgress) GetProgressPerMille() int32 {
+	if x != nil {
+		return x.ProgressPerMille
+	}
+	return 0
+}
+
+func (x *ReclusteringProgress) GetLast() *ClusteringVersion {
+	if x != nil {
+		return x.Last
+	}
+	return nil
+}
+
+func (x *ReclusteringProgress) GetNext() *ClusteringVersion {
+	if x != nil {
+		return x.Next
+	}
+	return nil
+}
+
+type QueryClusterSummariesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The LUCI Project.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// An AIP-160 style filter to select test failures in the project
+	// to cluster and calculate metrics for.
+	//
+	// Filtering supports a subset of [AIP-160 filtering](https://google.aip.dev/160).
+	//
+	// All values are case-sensitive.
+	//
+	// A bare value is searched for in the columns test_id and
+	// failure_reason. E.g. ninja or "test failed".
+	//
+	// You can use AND, OR and NOT (case sensitive) logical operators, along
+	// with grouping. '-' is equivalent to NOT. Multiple bare values are
+	// considered to be AND separated.  E.g. These are equivalent:
+	// hello world
+	// and:
+	// hello AND world
+	//
+	// More examples:
+	// a OR b
+	// a AND NOT(b or -c)
+	//
+	// You can filter particular columns with '=', '!=' and ':' (has) operators.
+	// The right hand side of the operator must be a simple value. E.g:
+	// test_id:telemetry
+	// -failure_reason:Timeout
+	// ingested_invocation_id="build-8822963500388678513"
+	//
+	// Supported columns to search on:
+	// - test_id
+	// - failure_reason
+	// - realm
+	// - ingested_invocation_id
+	// - cluster_algorithm
+	// - cluster_id
+	// - variant_hash
+	// - test_run_id
+	FailureFilter string `protobuf:"bytes,2,opt,name=failure_filter,json=failureFilter,proto3" json:"failure_filter,omitempty"`
+	// A comma-seperated list of fields to order the response by.
+	// Valid fields are "presubmit_rejects", "critical_failures_exonerated"
+	// and "failures".
+	// The default sorting order is ascending, to specify descending order
+	// for a field append a " desc" suffix.
+	// E.g. "presubmit_rejects desc, failures desc".
+	// See the order by section on aip.dev/132 for more details.
+	OrderBy string `protobuf:"bytes,3,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"`
+}
+
+func (x *QueryClusterSummariesRequest) Reset() {
+	*x = QueryClusterSummariesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryClusterSummariesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryClusterSummariesRequest) ProtoMessage() {}
+
+func (x *QueryClusterSummariesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryClusterSummariesRequest.ProtoReflect.Descriptor instead.
+func (*QueryClusterSummariesRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *QueryClusterSummariesRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *QueryClusterSummariesRequest) GetFailureFilter() string {
+	if x != nil {
+		return x.FailureFilter
+	}
+	return ""
+}
+
+func (x *QueryClusterSummariesRequest) GetOrderBy() string {
+	if x != nil {
+		return x.OrderBy
+	}
+	return ""
+}
+
+type QueryClusterSummariesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The clusters and impact metrics from the filtered failures.
+	ClusterSummaries []*ClusterSummary `protobuf:"bytes,1,rep,name=cluster_summaries,json=clusterSummaries,proto3" json:"cluster_summaries,omitempty"`
+}
+
+func (x *QueryClusterSummariesResponse) Reset() {
+	*x = QueryClusterSummariesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryClusterSummariesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryClusterSummariesResponse) ProtoMessage() {}
+
+func (x *QueryClusterSummariesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryClusterSummariesResponse.ProtoReflect.Descriptor instead.
+func (*QueryClusterSummariesResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *QueryClusterSummariesResponse) GetClusterSummaries() []*ClusterSummary {
+	if x != nil {
+		return x.ClusterSummaries
+	}
+	return nil
+}
+
+type ClusterSummary struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The cluster ID of this cluster.
+	ClusterId *ClusterId `protobuf:"bytes,1,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
+	// Title is a one-line description of the cluster.
+	Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"`
+	// The bug associated with the cluster. This will only be present for
+	// rules algorithm clusters.
+	Bug *AssociatedBug `protobuf:"bytes,3,opt,name=bug,proto3" json:"bug,omitempty"`
+	// The number of distinct developer changelists that failed at least one
+	// presubmit (CQ) run because of failure(s) in this cluster.
+	PresubmitRejects int64 `protobuf:"varint,4,opt,name=presubmit_rejects,json=presubmitRejects,proto3" json:"presubmit_rejects,omitempty"`
+	// The number of failures on test variants which were configured to be
+	// presubmit-blocking, which were exonerated (i.e. did not actually block
+	// presubmit) because infrastructure determined the test variant to be
+	// failing or too flaky at tip-of-tree. If this number is non-zero, it
+	// means a test variant which was configured to be presubmit-blocking is
+	// not stable enough to do so, and should be fixed or made non-blocking.
+	CriticalFailuresExonerated int64 `protobuf:"varint,5,opt,name=critical_failures_exonerated,json=criticalFailuresExonerated,proto3" json:"critical_failures_exonerated,omitempty"`
+	// The total number of test results in this cluster. Weetbix only
+	// clusters test results which are unexpected and have a status of crash,
+	// abort or fail.
+	Failures int64 `protobuf:"varint,6,opt,name=failures,proto3" json:"failures,omitempty"`
+}
+
+func (x *ClusterSummary) Reset() {
+	*x = ClusterSummary{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[10]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterSummary) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterSummary) ProtoMessage() {}
+
+func (x *ClusterSummary) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[10]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterSummary.ProtoReflect.Descriptor instead.
+func (*ClusterSummary) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *ClusterSummary) GetClusterId() *ClusterId {
+	if x != nil {
+		return x.ClusterId
+	}
+	return nil
+}
+
+func (x *ClusterSummary) GetTitle() string {
+	if x != nil {
+		return x.Title
+	}
+	return ""
+}
+
+func (x *ClusterSummary) GetBug() *AssociatedBug {
+	if x != nil {
+		return x.Bug
+	}
+	return nil
+}
+
+func (x *ClusterSummary) GetPresubmitRejects() int64 {
+	if x != nil {
+		return x.PresubmitRejects
+	}
+	return 0
+}
+
+func (x *ClusterSummary) GetCriticalFailuresExonerated() int64 {
+	if x != nil {
+		return x.CriticalFailuresExonerated
+	}
+	return 0
+}
+
+func (x *ClusterSummary) GetFailures() int64 {
+	if x != nil {
+		return x.Failures
+	}
+	return 0
+}
+
+type QueryClusterFailuresRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name of the cluster to retrieve failures for.
+	// Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}/failures.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+}
+
+func (x *QueryClusterFailuresRequest) Reset() {
+	*x = QueryClusterFailuresRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[11]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryClusterFailuresRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryClusterFailuresRequest) ProtoMessage() {}
+
+func (x *QueryClusterFailuresRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[11]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryClusterFailuresRequest.ProtoReflect.Descriptor instead.
+func (*QueryClusterFailuresRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *QueryClusterFailuresRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+type QueryClusterFailuresResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Example failures in the cluster.
+	// Limited to the most recent 2000 examples.
+	Failures []*DistinctClusterFailure `protobuf:"bytes,1,rep,name=failures,proto3" json:"failures,omitempty"`
+}
+
+func (x *QueryClusterFailuresResponse) Reset() {
+	*x = QueryClusterFailuresResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[12]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryClusterFailuresResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryClusterFailuresResponse) ProtoMessage() {}
+
+func (x *QueryClusterFailuresResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[12]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryClusterFailuresResponse.ProtoReflect.Descriptor instead.
+func (*QueryClusterFailuresResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *QueryClusterFailuresResponse) GetFailures() []*DistinctClusterFailure {
+	if x != nil {
+		return x.Failures
+	}
+	return nil
+}
+
+// DistinctClusterFailure represents a number of failures which have identical
+// properties. This provides slightly compressed transfer of examples.
+type DistinctClusterFailure struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The identity of the test.
+	TestId string `protobuf:"bytes,1,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// The test variant.
+	Variant *Variant `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"`
+	// Timestamp representing the start of the data retention period for the
+	// test results in this group.
+	// The partition time is usually the presubmit run start time (if any) or
+	// build start time.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// Details if the presubmit run associated with these results (if any).
+	PresubmitRun *DistinctClusterFailure_PresubmitRun `protobuf:"bytes,4,opt,name=presubmit_run,json=presubmitRun,proto3" json:"presubmit_run,omitempty"`
+	// Whether the build was critical to a presubmit run succeeding.
+	// If the build was not part of a presubmit run, this field should
+	// be ignored.
+	IsBuildCritical bool `protobuf:"varint,5,opt,name=is_build_critical,json=isBuildCritical,proto3" json:"is_build_critical,omitempty"`
+	// The exonerations applied to the test variant verdict.
+	Exonerations []*DistinctClusterFailure_Exoneration `protobuf:"bytes,6,rep,name=exonerations,proto3" json:"exonerations,omitempty"`
+	// The status of the build that contained this test result. Can be used
+	// to filter incomplete results (e.g. where build was cancelled or had
+	// an infra failure). Can also be used to filter builds with incomplete
+	// exonerations (e.g. build succeeded but some tests not exonerated).
+	// This is the build corresponding to ingested_invocation_id.
+	BuildStatus BuildStatus `protobuf:"varint,7,opt,name=build_status,json=buildStatus,proto3,enum=weetbix.v1.BuildStatus" json:"build_status,omitempty"`
+	// The invocation from which this test result was ingested. This is
+	// the top-level invocation that was ingested, an "invocation" being
+	// a container of test results as identified by the source test result
+	// system.
+	//
+	// For ResultDB, Weetbix ingests invocations corresponding to
+	// buildbucket builds.
+	IngestedInvocationId string `protobuf:"bytes,8,opt,name=ingested_invocation_id,json=ingestedInvocationId,proto3" json:"ingested_invocation_id,omitempty"`
+	// Is the ingested invocation blocked by this test variant? This is
+	// only true if all (non-skipped) test results for this test variant
+	// (in the ingested invocation) are unexpected failures.
+	//
+	// Exoneration does not factor into this value; check exonerations
+	// to see if the impact of this ingested invocation being blocked was
+	// mitigated by exoneration.
+	IsIngestedInvocationBlocked bool `protobuf:"varint,9,opt,name=is_ingested_invocation_blocked,json=isIngestedInvocationBlocked,proto3" json:"is_ingested_invocation_blocked,omitempty"`
+	// The unsubmitted changelists that were tested (if any).
+	// Up to 10 changelists are captured.
+	Changelists []*Changelist `protobuf:"bytes,10,rep,name=changelists,proto3" json:"changelists,omitempty"`
+	// The number of test results which have these properties.
+	Count int32 `protobuf:"varint,11,opt,name=count,proto3" json:"count,omitempty"`
+}
+
+func (x *DistinctClusterFailure) Reset() {
+	*x = DistinctClusterFailure{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[13]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *DistinctClusterFailure) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DistinctClusterFailure) ProtoMessage() {}
+
+func (x *DistinctClusterFailure) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[13]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DistinctClusterFailure.ProtoReflect.Descriptor instead.
+func (*DistinctClusterFailure) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *DistinctClusterFailure) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *DistinctClusterFailure) GetVariant() *Variant {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *DistinctClusterFailure) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *DistinctClusterFailure) GetPresubmitRun() *DistinctClusterFailure_PresubmitRun {
+	if x != nil {
+		return x.PresubmitRun
+	}
+	return nil
+}
+
+func (x *DistinctClusterFailure) GetIsBuildCritical() bool {
+	if x != nil {
+		return x.IsBuildCritical
+	}
+	return false
+}
+
+func (x *DistinctClusterFailure) GetExonerations() []*DistinctClusterFailure_Exoneration {
+	if x != nil {
+		return x.Exonerations
+	}
+	return nil
+}
+
+func (x *DistinctClusterFailure) GetBuildStatus() BuildStatus {
+	if x != nil {
+		return x.BuildStatus
+	}
+	return BuildStatus_BUILD_STATUS_UNSPECIFIED
+}
+
+func (x *DistinctClusterFailure) GetIngestedInvocationId() string {
+	if x != nil {
+		return x.IngestedInvocationId
+	}
+	return ""
+}
+
+func (x *DistinctClusterFailure) GetIsIngestedInvocationBlocked() bool {
+	if x != nil {
+		return x.IsIngestedInvocationBlocked
+	}
+	return false
+}
+
+func (x *DistinctClusterFailure) GetChangelists() []*Changelist {
+	if x != nil {
+		return x.Changelists
+	}
+	return nil
+}
+
+func (x *DistinctClusterFailure) GetCount() int32 {
+	if x != nil {
+		return x.Count
+	}
+	return 0
+}
+
+// TestResult captures information about a test result, sufficient to
+// cluster it. The fields requested here may be expanded over time.
+// For example, variant information may be requested in future.
+type ClusterRequest_TestResult struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Opaque tag supplied by the caller, to be returned in the
+	// response. Provided to assist correlating responses with requests.
+	// Does not need to be unique. Optional.
+	RequestTag string `protobuf:"bytes,1,opt,name=request_tag,json=requestTag,proto3" json:"request_tag,omitempty"`
+	// Identifier of the test (as reported to ResultDB).
+	// For chromium projects, this starts with ninja://.
+	TestId string `protobuf:"bytes,2,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// The failure reason of the test (if any).
+	FailureReason *FailureReason `protobuf:"bytes,3,opt,name=failure_reason,json=failureReason,proto3" json:"failure_reason,omitempty"`
+}
+
+func (x *ClusterRequest_TestResult) Reset() {
+	*x = ClusterRequest_TestResult{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[14]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterRequest_TestResult) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterRequest_TestResult) ProtoMessage() {}
+
+func (x *ClusterRequest_TestResult) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[14]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterRequest_TestResult.ProtoReflect.Descriptor instead.
+func (*ClusterRequest_TestResult) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{0, 0}
+}
+
+func (x *ClusterRequest_TestResult) GetRequestTag() string {
+	if x != nil {
+		return x.RequestTag
+	}
+	return ""
+}
+
+func (x *ClusterRequest_TestResult) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *ClusterRequest_TestResult) GetFailureReason() *FailureReason {
+	if x != nil {
+		return x.FailureReason
+	}
+	return nil
+}
+
+// The cluster(s) a test result is contained in.
+type ClusterResponse_ClusteredTestResult struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Opaque tag supplied by the caller in the request. Provided to assist
+	// the caller correlate responses with requests.
+	RequestTag string `protobuf:"bytes,1,opt,name=request_tag,json=requestTag,proto3" json:"request_tag,omitempty"`
+	// The clusters the test result is contained within.
+	Clusters []*ClusterResponse_ClusteredTestResult_ClusterEntry `protobuf:"bytes,2,rep,name=clusters,proto3" json:"clusters,omitempty"`
+}
+
+func (x *ClusterResponse_ClusteredTestResult) Reset() {
+	*x = ClusterResponse_ClusteredTestResult{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[15]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterResponse_ClusteredTestResult) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterResponse_ClusteredTestResult) ProtoMessage() {}
+
+func (x *ClusterResponse_ClusteredTestResult) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[15]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterResponse_ClusteredTestResult.ProtoReflect.Descriptor instead.
+func (*ClusterResponse_ClusteredTestResult) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{1, 0}
+}
+
+func (x *ClusterResponse_ClusteredTestResult) GetRequestTag() string {
+	if x != nil {
+		return x.RequestTag
+	}
+	return ""
+}
+
+func (x *ClusterResponse_ClusteredTestResult) GetClusters() []*ClusterResponse_ClusteredTestResult_ClusterEntry {
+	if x != nil {
+		return x.Clusters
+	}
+	return nil
+}
+
+// An individual cluster a test result is contained in.
+type ClusterResponse_ClusteredTestResult_ClusterEntry struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The unique identifier of the cluster.
+	// If the algorithm is "rules", the cluster ID is also a rule ID.
+	ClusterId *ClusterId `protobuf:"bytes,1,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
+	// The bug associated with the cluster, if any. This is only
+	// populated for clusters defined by a failure association rule,
+	// which associates specified failures to a bug.
+	Bug *AssociatedBug `protobuf:"bytes,2,opt,name=bug,proto3" json:"bug,omitempty"`
+}
+
+func (x *ClusterResponse_ClusteredTestResult_ClusterEntry) Reset() {
+	*x = ClusterResponse_ClusteredTestResult_ClusterEntry{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[16]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterResponse_ClusteredTestResult_ClusterEntry) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterResponse_ClusteredTestResult_ClusterEntry) ProtoMessage() {}
+
+func (x *ClusterResponse_ClusteredTestResult_ClusterEntry) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[16]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterResponse_ClusteredTestResult_ClusterEntry.ProtoReflect.Descriptor instead.
+func (*ClusterResponse_ClusteredTestResult_ClusterEntry) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{1, 0, 0}
+}
+
+func (x *ClusterResponse_ClusteredTestResult_ClusterEntry) GetClusterId() *ClusterId {
+	if x != nil {
+		return x.ClusterId
+	}
+	return nil
+}
+
+func (x *ClusterResponse_ClusteredTestResult_ClusterEntry) GetBug() *AssociatedBug {
+	if x != nil {
+		return x.Bug
+	}
+	return nil
+}
+
+type Cluster_MetricValues struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The metric value for the last day.
+	OneDay *Cluster_MetricValues_Counts `protobuf:"bytes,2,opt,name=one_day,json=oneDay,proto3" json:"one_day,omitempty"`
+	// The metric value for the last three days.
+	ThreeDay *Cluster_MetricValues_Counts `protobuf:"bytes,3,opt,name=three_day,json=threeDay,proto3" json:"three_day,omitempty"`
+	// The metric value for the last week.
+	SevenDay *Cluster_MetricValues_Counts `protobuf:"bytes,4,opt,name=seven_day,json=sevenDay,proto3" json:"seven_day,omitempty"`
+}
+
+func (x *Cluster_MetricValues) Reset() {
+	*x = Cluster_MetricValues{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[17]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Cluster_MetricValues) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Cluster_MetricValues) ProtoMessage() {}
+
+func (x *Cluster_MetricValues) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[17]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Cluster_MetricValues.ProtoReflect.Descriptor instead.
+func (*Cluster_MetricValues) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{5, 0}
+}
+
+func (x *Cluster_MetricValues) GetOneDay() *Cluster_MetricValues_Counts {
+	if x != nil {
+		return x.OneDay
+	}
+	return nil
+}
+
+func (x *Cluster_MetricValues) GetThreeDay() *Cluster_MetricValues_Counts {
+	if x != nil {
+		return x.ThreeDay
+	}
+	return nil
+}
+
+func (x *Cluster_MetricValues) GetSevenDay() *Cluster_MetricValues_Counts {
+	if x != nil {
+		return x.SevenDay
+	}
+	return nil
+}
+
+type Cluster_MetricValues_Counts struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The value of the metric (summed over all failures).
+	Nominal int64 `protobuf:"varint,1,opt,name=nominal,proto3" json:"nominal,omitempty"`
+}
+
+func (x *Cluster_MetricValues_Counts) Reset() {
+	*x = Cluster_MetricValues_Counts{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[18]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Cluster_MetricValues_Counts) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Cluster_MetricValues_Counts) ProtoMessage() {}
+
+func (x *Cluster_MetricValues_Counts) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[18]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Cluster_MetricValues_Counts.ProtoReflect.Descriptor instead.
+func (*Cluster_MetricValues_Counts) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{5, 0, 0}
+}
+
+func (x *Cluster_MetricValues_Counts) GetNominal() int64 {
+	if x != nil {
+		return x.Nominal
+	}
+	return 0
+}
+
+// Representation of an exoneration. An exoneration means the subject of
+// the test (e.g. a CL) is absolved from blame for the unexpected results
+// of the test variant.
+type DistinctClusterFailure_Exoneration struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The machine-readable reason for the exoneration.
+	Reason ExonerationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=weetbix.v1.ExonerationReason" json:"reason,omitempty"`
+}
+
+func (x *DistinctClusterFailure_Exoneration) Reset() {
+	*x = DistinctClusterFailure_Exoneration{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[19]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *DistinctClusterFailure_Exoneration) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DistinctClusterFailure_Exoneration) ProtoMessage() {}
+
+func (x *DistinctClusterFailure_Exoneration) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[19]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DistinctClusterFailure_Exoneration.ProtoReflect.Descriptor instead.
+func (*DistinctClusterFailure_Exoneration) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{13, 0}
+}
+
+func (x *DistinctClusterFailure_Exoneration) GetReason() ExonerationReason {
+	if x != nil {
+		return x.Reason
+	}
+	return ExonerationReason_EXONERATION_REASON_UNSPECIFIED
+}
+
+// Representation of a presubmit run (e.g. LUCI CV Run).
+type DistinctClusterFailure_PresubmitRun struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Identity of the presubmit run that contains this test result.
+	// This should be unique per "CQ+1"/"CQ+2" attempt on gerrit.
+	//
+	// One presumbit run MAY have many ingested invocation IDs (e.g. for its
+	// various tryjobs), but every ingested invocation ID only ever has one
+	// presubmit run ID (if any).
+	//
+	// All test results for the same presubmit run will have one
+	// partition_time.
+	//
+	// If the test result was not collected as part of a presubmit run,
+	// this is unset.
+	PresubmitRunId *PresubmitRunId `protobuf:"bytes,1,opt,name=presubmit_run_id,json=presubmitRunId,proto3" json:"presubmit_run_id,omitempty"`
+	// The owner of the presubmit run (if any).
+	// This is the owner of the CL on which CQ+1/CQ+2 was clicked
+	// (even in case of presubmit run with multiple CLs).
+	// There is scope for this field to become an email address if privacy
+	// approval is obtained, until then it is "automation" (for automation
+	// service accounts) and "user" otherwise.
+	Owner string `protobuf:"bytes,2,opt,name=owner,proto3" json:"owner,omitempty"`
+	// The mode of the presubmit run. E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+	Mode PresubmitRunMode `protobuf:"varint,3,opt,name=mode,proto3,enum=weetbix.v1.PresubmitRunMode" json:"mode,omitempty"`
+}
+
+func (x *DistinctClusterFailure_PresubmitRun) Reset() {
+	*x = DistinctClusterFailure_PresubmitRun{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[20]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *DistinctClusterFailure_PresubmitRun) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DistinctClusterFailure_PresubmitRun) ProtoMessage() {}
+
+func (x *DistinctClusterFailure_PresubmitRun) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[20]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use DistinctClusterFailure_PresubmitRun.ProtoReflect.Descriptor instead.
+func (*DistinctClusterFailure_PresubmitRun) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP(), []int{13, 1}
+}
+
+func (x *DistinctClusterFailure_PresubmitRun) GetPresubmitRunId() *PresubmitRunId {
+	if x != nil {
+		return x.PresubmitRunId
+	}
+	return nil
+}
+
+func (x *DistinctClusterFailure_PresubmitRun) GetOwner() string {
+	if x != nil {
+		return x.Owner
+	}
+	return ""
+}
+
+func (x *DistinctClusterFailure_PresubmitRun) GetMode() PresubmitRunMode {
+	if x != nil {
+		return x.Mode
+	}
+	return PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED
+}
+
+var File_infra_appengine_weetbix_proto_v1_clusters_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDesc = []byte{
+	0x0a, 0x2f, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31,
+	0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x31, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f,
+	0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x1a, 0x35, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f,
+	0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xff, 0x01, 0x0a, 0x0e, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x12, 0x48, 0x0a, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x72, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c,
+	0x74, 0x52, 0x0b, 0x74, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x1a, 0x88,
+	0x01, 0x0a, 0x0a, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1f, 0x0a,
+	0x0b, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x61, 0x67, 0x12, 0x17,
+	0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x40, 0x0a, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x19, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x61, 0x69,
+	0x6c, 0x75, 0x72, 0x65, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x52, 0x0d, 0x66, 0x61, 0x69, 0x6c,
+	0x75, 0x72, 0x65, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0xcc, 0x03, 0x0a, 0x0f, 0x43, 0x6c,
+	0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x65, 0x0a,
+	0x16, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x5f, 0x74, 0x65, 0x73, 0x74, 0x5f,
+	0x72, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x65, 0x64, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x52, 0x14,
+	0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73,
+	0x75, 0x6c, 0x74, 0x73, 0x12, 0x4c, 0x0a, 0x12, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69,
+	0x6e, 0x67, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c,
+	0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52,
+	0x11, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69,
+	0x6f, 0x6e, 0x1a, 0x83, 0x02, 0x0a, 0x13, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x65, 0x64,
+	0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x74, 0x61, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x61, 0x67, 0x12, 0x58, 0x0a, 0x08, 0x63,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x3c, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x65, 0x64, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x2e, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x63, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x73, 0x1a, 0x71, 0x0a, 0x0c, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x34, 0x0a, 0x0a, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72,
+	0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64,
+	0x52, 0x09, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x2b, 0x0a, 0x03, 0x62,
+	0x75, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64,
+	0x42, 0x75, 0x67, 0x52, 0x03, 0x62, 0x75, 0x67, 0x22, 0xc6, 0x01, 0x0a, 0x11, 0x43, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d,
+	0x0a, 0x12, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x73, 0x5f, 0x76, 0x65, 0x72,
+	0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x11, 0x61, 0x6c, 0x67, 0x6f,
+	0x72, 0x69, 0x74, 0x68, 0x6d, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x3f, 0x0a,
+	0x0d, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70,
+	0x52, 0x0c, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x41,
+	0x0a, 0x0e, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
+	0x6d, 0x70, 0x52, 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f,
+	0x6e, 0x22, 0x47, 0x0a, 0x17, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06,
+	0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x70, 0x61,
+	0x72, 0x65, 0x6e, 0x74, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x02, 0x20,
+	0x03, 0x28, 0x09, 0x52, 0x05, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x4b, 0x0a, 0x18, 0x42, 0x61,
+	0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65,
+	0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x08, 0x63,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x22, 0xa5, 0x05, 0x0a, 0x07, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x61, 0x73, 0x5f, 0x65,
+	0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x68, 0x61,
+	0x73, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c,
+	0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x5b,
+	0x0a, 0x19, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x63, 0x6c, 0x73, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x65,
+	0x64, 0x5f, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x73, 0x52, 0x16, 0x75, 0x73, 0x65, 0x72, 0x43, 0x6c, 0x73, 0x46, 0x61, 0x69, 0x6c,
+	0x65, 0x64, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x12, 0x62, 0x0a, 0x1c, 0x63,
+	0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73,
+	0x5f, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x73, 0x52, 0x1a, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x46, 0x61, 0x69,
+	0x6c, 0x75, 0x72, 0x65, 0x73, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x12,
+	0x3c, 0x0a, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c,
+	0x75, 0x65, 0x73, 0x52, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x12, 0x4d, 0x0a,
+	0x23, 0x65, 0x71, 0x75, 0x69, 0x76, 0x61, 0x6c, 0x65, 0x6e, 0x74, 0x5f, 0x66, 0x61, 0x69, 0x6c,
+	0x75, 0x72, 0x65, 0x5f, 0x61, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f,
+	0x72, 0x75, 0x6c, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x20, 0x65, 0x71, 0x75, 0x69,
+	0x76, 0x61, 0x6c, 0x65, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x41, 0x73, 0x73,
+	0x6f, 0x63, 0x69, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x75, 0x6c, 0x65, 0x1a, 0x80, 0x02, 0x0a,
+	0x0c, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x12, 0x40, 0x0a,
+	0x07, 0x6f, 0x6e, 0x65, 0x5f, 0x64, 0x61, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73,
+	0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x06, 0x6f, 0x6e, 0x65, 0x44, 0x61, 0x79, 0x12,
+	0x44, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, 0x65, 0x5f, 0x64, 0x61, 0x79, 0x18, 0x03, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x27, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x2e, 0x4d, 0x65, 0x74, 0x72, 0x69, 0x63, 0x56, 0x61,
+	0x6c, 0x75, 0x65, 0x73, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x73, 0x52, 0x08, 0x74, 0x68, 0x72,
+	0x65, 0x65, 0x44, 0x61, 0x79, 0x12, 0x44, 0x0a, 0x09, 0x73, 0x65, 0x76, 0x65, 0x6e, 0x5f, 0x64,
+	0x61, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x2e, 0x4d, 0x65,
+	0x74, 0x72, 0x69, 0x63, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x2e, 0x43, 0x6f, 0x75, 0x6e, 0x74,
+	0x73, 0x52, 0x08, 0x73, 0x65, 0x76, 0x65, 0x6e, 0x44, 0x61, 0x79, 0x1a, 0x22, 0x0a, 0x06, 0x43,
+	0x6f, 0x75, 0x6e, 0x74, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x6c,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x6e, 0x6f, 0x6d, 0x69, 0x6e, 0x61, 0x6c, 0x22,
+	0x34, 0x0a, 0x1e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69,
+	0x6e, 0x67, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
+	0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0xbe, 0x01, 0x0a, 0x14, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x12,
+	0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61,
+	0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x12, 0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x5f, 0x70,
+	0x65, 0x72, 0x5f, 0x6d, 0x69, 0x6c, 0x6c, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x10,
+	0x70, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x50, 0x65, 0x72, 0x4d, 0x69, 0x6c, 0x6c, 0x65,
+	0x12, 0x31, 0x0a, 0x04, 0x6c, 0x61, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x6c,
+	0x61, 0x73, 0x74, 0x12, 0x31, 0x0a, 0x04, 0x6e, 0x65, 0x78, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e,
+	0x52, 0x04, 0x6e, 0x65, 0x78, 0x74, 0x22, 0x7a, 0x0a, 0x1c, 0x51, 0x75, 0x65, 0x72, 0x79, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x69, 0x65, 0x73, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x12, 0x25, 0x0a, 0x0e, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x66, 0x69, 0x6c, 0x74,
+	0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72,
+	0x65, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x12, 0x19, 0x0a, 0x08, 0x6f, 0x72, 0x64, 0x65, 0x72,
+	0x5f, 0x62, 0x79, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6f, 0x72, 0x64, 0x65, 0x72,
+	0x42, 0x79, 0x22, 0x68, 0x0a, 0x1d, 0x51, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6c, 0x75, 0x73, 0x74,
+	0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x12, 0x47, 0x0a, 0x11, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x73,
+	0x75, 0x6d, 0x6d, 0x61, 0x72, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1a,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x52, 0x10, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x69, 0x65, 0x73, 0x22, 0x94, 0x02, 0x0a,
+	0x0e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x79, 0x12,
+	0x34, 0x0a, 0x0a, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x52, 0x09, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x69, 0x74, 0x6c, 0x65, 0x12, 0x2b, 0x0a, 0x03, 0x62,
+	0x75, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64,
+	0x42, 0x75, 0x67, 0x52, 0x03, 0x62, 0x75, 0x67, 0x12, 0x2b, 0x0a, 0x11, 0x70, 0x72, 0x65, 0x73,
+	0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x65, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x04, 0x20,
+	0x01, 0x28, 0x03, 0x52, 0x10, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x65,
+	0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x40, 0x0a, 0x1c, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61,
+	0x6c, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x5f, 0x65, 0x78, 0x6f, 0x6e, 0x65,
+	0x72, 0x61, 0x74, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x1a, 0x63, 0x72, 0x69,
+	0x74, 0x69, 0x63, 0x61, 0x6c, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x45, 0x78, 0x6f,
+	0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x73, 0x22, 0x35, 0x0a, 0x1b, 0x51, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65,
+	0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x22, 0x5e, 0x0a, 0x1c, 0x51, 0x75,
+	0x65, 0x72, 0x79, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72,
+	0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x3e, 0x0a, 0x08, 0x66, 0x61,
+	0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x74, 0x69, 0x6e,
+	0x63, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65,
+	0x52, 0x08, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x22, 0xe5, 0x06, 0x0a, 0x16, 0x44,
+	0x69, 0x73, 0x74, 0x69, 0x6e, 0x63, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x46, 0x61,
+	0x69, 0x6c, 0x75, 0x72, 0x65, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x2d,
+	0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x12, 0x41, 0x0a,
+	0x0e, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d,
+	0x70, 0x52, 0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65,
+	0x12, 0x54, 0x0a, 0x0d, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x5f, 0x72, 0x75,
+	0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x2f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x63, 0x74, 0x43, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x2e, 0x50, 0x72, 0x65, 0x73,
+	0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62,
+	0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x12, 0x2a, 0x0a, 0x11, 0x69, 0x73, 0x5f, 0x62, 0x75, 0x69,
+	0x6c, 0x64, 0x5f, 0x63, 0x72, 0x69, 0x74, 0x69, 0x63, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x08, 0x52, 0x0f, 0x69, 0x73, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x43, 0x72, 0x69, 0x74, 0x69, 0x63,
+	0x61, 0x6c, 0x12, 0x52, 0x0a, 0x0c, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2e, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x69, 0x73, 0x74, 0x69, 0x6e, 0x63, 0x74, 0x43, 0x6c,
+	0x75, 0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x2e, 0x45, 0x78, 0x6f,
+	0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0c, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x3a, 0x0a, 0x0c, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x5f,
+	0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x42, 0x75, 0x69, 0x6c, 0x64, 0x53,
+	0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x0b, 0x62, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x12, 0x34, 0x0a, 0x16, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69,
+	0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x08, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x14, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x43, 0x0a, 0x1e, 0x69, 0x73, 0x5f, 0x69,
+	0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x5f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08,
+	0x52, 0x1b, 0x69, 0x73, 0x49, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x42, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x12, 0x38, 0x0a,
+	0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x18, 0x0a, 0x20, 0x03,
+	0x28, 0x0b, 0x32, 0x16, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6e,
+	0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74,
+	0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x1a, 0x44, 0x0a,
+	0x0b, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x35, 0x0a, 0x06,
+	0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x45, 0x78, 0x6f, 0x6e, 0x65, 0x72,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x52, 0x06, 0x72, 0x65, 0x61,
+	0x73, 0x6f, 0x6e, 0x1a, 0x9c, 0x01, 0x0a, 0x0c, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69,
+	0x74, 0x52, 0x75, 0x6e, 0x12, 0x44, 0x0a, 0x10, 0x70, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69,
+	0x74, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73,
+	0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x52, 0x0e, 0x70, 0x72, 0x65, 0x73,
+	0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6f, 0x77,
+	0x6e, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6f, 0x77, 0x6e, 0x65, 0x72,
+	0x12, 0x30, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1c,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x65, 0x73,
+	0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x52, 0x04, 0x6d, 0x6f,
+	0x64, 0x65, 0x32, 0xf1, 0x03, 0x0a, 0x08, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x12,
+	0x44, 0x0a, 0x07, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x1a, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x57, 0x0a, 0x08, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65,
+	0x74, 0x12, 0x23, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x42,
+	0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x73, 0x52,
+	0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x47, 0x65, 0x74, 0x43, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x69,
+	0x0a, 0x17, 0x47, 0x65, 0x74, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e,
+	0x67, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2a, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73,
+	0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x50, 0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x69, 0x6e, 0x67, 0x50,
+	0x72, 0x6f, 0x67, 0x72, 0x65, 0x73, 0x73, 0x22, 0x00, 0x12, 0x6e, 0x0a, 0x15, 0x51, 0x75, 0x65,
+	0x72, 0x79, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x69,
+	0x65, 0x73, 0x12, 0x28, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x51, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d,
+	0x61, 0x72, 0x69, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x43,
+	0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x53, 0x75, 0x6d, 0x6d, 0x61, 0x72, 0x69, 0x65, 0x73, 0x52,
+	0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x6b, 0x0a, 0x14, 0x51, 0x75, 0x65,
+	0x72, 0x79, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65,
+	0x73, 0x12, 0x27, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51,
+	0x75, 0x65, 0x72, 0x79, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75,
+	0x72, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x43, 0x6c, 0x75,
+	0x73, 0x74, 0x65, 0x72, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70,
+	0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f,
+	0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes = make([]protoimpl.MessageInfo, 21)
+var file_infra_appengine_weetbix_proto_v1_clusters_proto_goTypes = []interface{}{
+	(*ClusterRequest)(nil),                                   // 0: weetbix.v1.ClusterRequest
+	(*ClusterResponse)(nil),                                  // 1: weetbix.v1.ClusterResponse
+	(*ClusteringVersion)(nil),                                // 2: weetbix.v1.ClusteringVersion
+	(*BatchGetClustersRequest)(nil),                          // 3: weetbix.v1.BatchGetClustersRequest
+	(*BatchGetClustersResponse)(nil),                         // 4: weetbix.v1.BatchGetClustersResponse
+	(*Cluster)(nil),                                          // 5: weetbix.v1.Cluster
+	(*GetReclusteringProgressRequest)(nil),                   // 6: weetbix.v1.GetReclusteringProgressRequest
+	(*ReclusteringProgress)(nil),                             // 7: weetbix.v1.ReclusteringProgress
+	(*QueryClusterSummariesRequest)(nil),                     // 8: weetbix.v1.QueryClusterSummariesRequest
+	(*QueryClusterSummariesResponse)(nil),                    // 9: weetbix.v1.QueryClusterSummariesResponse
+	(*ClusterSummary)(nil),                                   // 10: weetbix.v1.ClusterSummary
+	(*QueryClusterFailuresRequest)(nil),                      // 11: weetbix.v1.QueryClusterFailuresRequest
+	(*QueryClusterFailuresResponse)(nil),                     // 12: weetbix.v1.QueryClusterFailuresResponse
+	(*DistinctClusterFailure)(nil),                           // 13: weetbix.v1.DistinctClusterFailure
+	(*ClusterRequest_TestResult)(nil),                        // 14: weetbix.v1.ClusterRequest.TestResult
+	(*ClusterResponse_ClusteredTestResult)(nil),              // 15: weetbix.v1.ClusterResponse.ClusteredTestResult
+	(*ClusterResponse_ClusteredTestResult_ClusterEntry)(nil), // 16: weetbix.v1.ClusterResponse.ClusteredTestResult.ClusterEntry
+	(*Cluster_MetricValues)(nil),                             // 17: weetbix.v1.Cluster.MetricValues
+	(*Cluster_MetricValues_Counts)(nil),                      // 18: weetbix.v1.Cluster.MetricValues.Counts
+	(*DistinctClusterFailure_Exoneration)(nil),               // 19: weetbix.v1.DistinctClusterFailure.Exoneration
+	(*DistinctClusterFailure_PresubmitRun)(nil),              // 20: weetbix.v1.DistinctClusterFailure.PresubmitRun
+	(*timestamppb.Timestamp)(nil),                            // 21: google.protobuf.Timestamp
+	(*ClusterId)(nil),                                        // 22: weetbix.v1.ClusterId
+	(*AssociatedBug)(nil),                                    // 23: weetbix.v1.AssociatedBug
+	(*Variant)(nil),                                          // 24: weetbix.v1.Variant
+	(BuildStatus)(0),                                         // 25: weetbix.v1.BuildStatus
+	(*Changelist)(nil),                                       // 26: weetbix.v1.Changelist
+	(*FailureReason)(nil),                                    // 27: weetbix.v1.FailureReason
+	(ExonerationReason)(0),                                   // 28: weetbix.v1.ExonerationReason
+	(*PresubmitRunId)(nil),                                   // 29: weetbix.v1.PresubmitRunId
+	(PresubmitRunMode)(0),                                    // 30: weetbix.v1.PresubmitRunMode
+}
+var file_infra_appengine_weetbix_proto_v1_clusters_proto_depIdxs = []int32{
+	14, // 0: weetbix.v1.ClusterRequest.test_results:type_name -> weetbix.v1.ClusterRequest.TestResult
+	15, // 1: weetbix.v1.ClusterResponse.clustered_test_results:type_name -> weetbix.v1.ClusterResponse.ClusteredTestResult
+	2,  // 2: weetbix.v1.ClusterResponse.clustering_version:type_name -> weetbix.v1.ClusteringVersion
+	21, // 3: weetbix.v1.ClusteringVersion.rules_version:type_name -> google.protobuf.Timestamp
+	21, // 4: weetbix.v1.ClusteringVersion.config_version:type_name -> google.protobuf.Timestamp
+	5,  // 5: weetbix.v1.BatchGetClustersResponse.clusters:type_name -> weetbix.v1.Cluster
+	17, // 6: weetbix.v1.Cluster.user_cls_failed_presubmit:type_name -> weetbix.v1.Cluster.MetricValues
+	17, // 7: weetbix.v1.Cluster.critical_failures_exonerated:type_name -> weetbix.v1.Cluster.MetricValues
+	17, // 8: weetbix.v1.Cluster.failures:type_name -> weetbix.v1.Cluster.MetricValues
+	2,  // 9: weetbix.v1.ReclusteringProgress.last:type_name -> weetbix.v1.ClusteringVersion
+	2,  // 10: weetbix.v1.ReclusteringProgress.next:type_name -> weetbix.v1.ClusteringVersion
+	10, // 11: weetbix.v1.QueryClusterSummariesResponse.cluster_summaries:type_name -> weetbix.v1.ClusterSummary
+	22, // 12: weetbix.v1.ClusterSummary.cluster_id:type_name -> weetbix.v1.ClusterId
+	23, // 13: weetbix.v1.ClusterSummary.bug:type_name -> weetbix.v1.AssociatedBug
+	13, // 14: weetbix.v1.QueryClusterFailuresResponse.failures:type_name -> weetbix.v1.DistinctClusterFailure
+	24, // 15: weetbix.v1.DistinctClusterFailure.variant:type_name -> weetbix.v1.Variant
+	21, // 16: weetbix.v1.DistinctClusterFailure.partition_time:type_name -> google.protobuf.Timestamp
+	20, // 17: weetbix.v1.DistinctClusterFailure.presubmit_run:type_name -> weetbix.v1.DistinctClusterFailure.PresubmitRun
+	19, // 18: weetbix.v1.DistinctClusterFailure.exonerations:type_name -> weetbix.v1.DistinctClusterFailure.Exoneration
+	25, // 19: weetbix.v1.DistinctClusterFailure.build_status:type_name -> weetbix.v1.BuildStatus
+	26, // 20: weetbix.v1.DistinctClusterFailure.changelists:type_name -> weetbix.v1.Changelist
+	27, // 21: weetbix.v1.ClusterRequest.TestResult.failure_reason:type_name -> weetbix.v1.FailureReason
+	16, // 22: weetbix.v1.ClusterResponse.ClusteredTestResult.clusters:type_name -> weetbix.v1.ClusterResponse.ClusteredTestResult.ClusterEntry
+	22, // 23: weetbix.v1.ClusterResponse.ClusteredTestResult.ClusterEntry.cluster_id:type_name -> weetbix.v1.ClusterId
+	23, // 24: weetbix.v1.ClusterResponse.ClusteredTestResult.ClusterEntry.bug:type_name -> weetbix.v1.AssociatedBug
+	18, // 25: weetbix.v1.Cluster.MetricValues.one_day:type_name -> weetbix.v1.Cluster.MetricValues.Counts
+	18, // 26: weetbix.v1.Cluster.MetricValues.three_day:type_name -> weetbix.v1.Cluster.MetricValues.Counts
+	18, // 27: weetbix.v1.Cluster.MetricValues.seven_day:type_name -> weetbix.v1.Cluster.MetricValues.Counts
+	28, // 28: weetbix.v1.DistinctClusterFailure.Exoneration.reason:type_name -> weetbix.v1.ExonerationReason
+	29, // 29: weetbix.v1.DistinctClusterFailure.PresubmitRun.presubmit_run_id:type_name -> weetbix.v1.PresubmitRunId
+	30, // 30: weetbix.v1.DistinctClusterFailure.PresubmitRun.mode:type_name -> weetbix.v1.PresubmitRunMode
+	0,  // 31: weetbix.v1.Clusters.Cluster:input_type -> weetbix.v1.ClusterRequest
+	3,  // 32: weetbix.v1.Clusters.BatchGet:input_type -> weetbix.v1.BatchGetClustersRequest
+	6,  // 33: weetbix.v1.Clusters.GetReclusteringProgress:input_type -> weetbix.v1.GetReclusteringProgressRequest
+	8,  // 34: weetbix.v1.Clusters.QueryClusterSummaries:input_type -> weetbix.v1.QueryClusterSummariesRequest
+	11, // 35: weetbix.v1.Clusters.QueryClusterFailures:input_type -> weetbix.v1.QueryClusterFailuresRequest
+	1,  // 36: weetbix.v1.Clusters.Cluster:output_type -> weetbix.v1.ClusterResponse
+	4,  // 37: weetbix.v1.Clusters.BatchGet:output_type -> weetbix.v1.BatchGetClustersResponse
+	7,  // 38: weetbix.v1.Clusters.GetReclusteringProgress:output_type -> weetbix.v1.ReclusteringProgress
+	9,  // 39: weetbix.v1.Clusters.QueryClusterSummaries:output_type -> weetbix.v1.QueryClusterSummariesResponse
+	12, // 40: weetbix.v1.Clusters.QueryClusterFailures:output_type -> weetbix.v1.QueryClusterFailuresResponse
+	36, // [36:41] is the sub-list for method output_type
+	31, // [31:36] is the sub-list for method input_type
+	31, // [31:31] is the sub-list for extension type_name
+	31, // [31:31] is the sub-list for extension extendee
+	0,  // [0:31] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_clusters_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_clusters_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_clusters_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_v1_common_proto_init()
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_init()
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusteringVersion); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetClustersRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BatchGetClustersResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Cluster); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetReclusteringProgressRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ReclusteringProgress); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryClusterSummariesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryClusterSummariesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterSummary); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryClusterFailuresRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryClusterFailuresResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*DistinctClusterFailure); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterRequest_TestResult); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterResponse_ClusteredTestResult); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterResponse_ClusteredTestResult_ClusterEntry); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Cluster_MetricValues); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Cluster_MetricValues_Counts); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*DistinctClusterFailure_Exoneration); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*DistinctClusterFailure_PresubmitRun); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   21,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_clusters_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_clusters_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_clusters_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_clusters_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_clusters_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_clusters_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_clusters_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// ClustersClient is the client API for Clusters service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type ClustersClient interface {
+	// Identifies the cluster(s) for one or more test failure(s).
+	//
+	// This RPC returns the clusters of each test result, using
+	// current suggested cluster algorithms, configured failure
+	// association rules, and ingested project configuration with
+	// a bounded staleness of up to one minute. (Returned clusters
+	// may be based on project configuration and configured failure
+	// association rules that is up to one minute out-of-date).
+	//
+	// As at April 2022, the implementation does not use stale
+	// rules, but you are instructed NOT to rely on this property to
+	// allow reversion to the faster implementation that is tolerant
+	// to higher QPS in future. If your use case require strong reads
+	// (e.g. you want to call cluster immediately after updating a rule),
+	// please contact Weetbix owners. We may be able to provide a
+	// request flag to select this processing behaviour.
+	//
+	// This RPC is a pure query API and does not lead to the ingestion of the
+	// test failures by Weetbix (e.g. for cluster impact calculations).
+	Cluster(ctx context.Context, in *ClusterRequest, opts ...grpc.CallOption) (*ClusterResponse, error)
+	// Reads information about the given clusters.
+	//
+	// Please consult Weetbix owners before adding additional calls to this
+	// RPC, as the implementation currently calls back to BigQuery and as
+	// such, is not cost-optimised if many queries are to be made.
+	//
+	// As of writing (April 13, 2022) this query reads ~1 GB per call for
+	// the largest LUCI Project, which translates to a cost of 0.5 US cents
+	// per query at published pricing (US$5/TB analyzed for BigQuery).
+	//
+	// Changes to this RPC should comply with https://google.aip.dev/231.
+	BatchGet(ctx context.Context, in *BatchGetClustersRequest, opts ...grpc.CallOption) (*BatchGetClustersResponse, error)
+	// Reads current progress re-clustering the given project. Re-clustering
+	// means updating the clusters each failure is in to reflect the latest
+	// failure association rules, suggested clustering algorithms and
+	// clustering configuration.
+	GetReclusteringProgress(ctx context.Context, in *GetReclusteringProgressRequest, opts ...grpc.CallOption) (*ReclusteringProgress, error)
+	// Queries summary information about top clusters.
+	//
+	// The set of test failures used as input to the clustering can be
+	// specified using the failure_filter field on the request.
+	// The returned clusters include only the impact derived from the
+	// filtered failures.
+	//
+	// This allows investigation of the highest impact clusters for some
+	// subset of the failure data in a project. For example, a filter string
+	// of "failure_reason:ssh" would find all of the clusters where any test
+	// results mention "ssh" in their failure reason, and show how big the
+	// impact from these ssh failures is in each cluster. This is useful when
+	// investigating specific problems, or ownership areas of the tests.
+	//
+	// Please consult Weetbix owners before adding additional calls to this
+	// RPC, as the implementation currently calls back to BigQuery and as
+	// such, is not cost-optimised if many queries are to be made.
+	//
+	// As of writing (April 13, 2022) this query reads up to 10 GB per call for
+	// the largest LUCI Project, which translates to a cost of up to 5 US cents
+	// per query at published pricing (US$5/TB analyzed for BigQuery).
+	QueryClusterSummaries(ctx context.Context, in *QueryClusterSummariesRequest, opts ...grpc.CallOption) (*QueryClusterSummariesResponse, error)
+	// Queries examples of failures in the given cluster.
+	//
+	// Please consult Weetbix owners before adding additional calls to this
+	// RPC, as the implementation currently calls back to BigQuery and as
+	// such, is not cost-optimised if many queries are to be made.
+	QueryClusterFailures(ctx context.Context, in *QueryClusterFailuresRequest, opts ...grpc.CallOption) (*QueryClusterFailuresResponse, error)
+}
+type clustersPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewClustersPRPCClient(client *prpc.Client) ClustersClient {
+	return &clustersPRPCClient{client}
+}
+
+func (c *clustersPRPCClient) Cluster(ctx context.Context, in *ClusterRequest, opts ...grpc.CallOption) (*ClusterResponse, error) {
+	out := new(ClusterResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Clusters", "Cluster", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersPRPCClient) BatchGet(ctx context.Context, in *BatchGetClustersRequest, opts ...grpc.CallOption) (*BatchGetClustersResponse, error) {
+	out := new(BatchGetClustersResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Clusters", "BatchGet", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersPRPCClient) GetReclusteringProgress(ctx context.Context, in *GetReclusteringProgressRequest, opts ...grpc.CallOption) (*ReclusteringProgress, error) {
+	out := new(ReclusteringProgress)
+	err := c.client.Call(ctx, "weetbix.v1.Clusters", "GetReclusteringProgress", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersPRPCClient) QueryClusterSummaries(ctx context.Context, in *QueryClusterSummariesRequest, opts ...grpc.CallOption) (*QueryClusterSummariesResponse, error) {
+	out := new(QueryClusterSummariesResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Clusters", "QueryClusterSummaries", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersPRPCClient) QueryClusterFailures(ctx context.Context, in *QueryClusterFailuresRequest, opts ...grpc.CallOption) (*QueryClusterFailuresResponse, error) {
+	out := new(QueryClusterFailuresResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Clusters", "QueryClusterFailures", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type clustersClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewClustersClient(cc grpc.ClientConnInterface) ClustersClient {
+	return &clustersClient{cc}
+}
+
+func (c *clustersClient) Cluster(ctx context.Context, in *ClusterRequest, opts ...grpc.CallOption) (*ClusterResponse, error) {
+	out := new(ClusterResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Clusters/Cluster", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersClient) BatchGet(ctx context.Context, in *BatchGetClustersRequest, opts ...grpc.CallOption) (*BatchGetClustersResponse, error) {
+	out := new(BatchGetClustersResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Clusters/BatchGet", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersClient) GetReclusteringProgress(ctx context.Context, in *GetReclusteringProgressRequest, opts ...grpc.CallOption) (*ReclusteringProgress, error) {
+	out := new(ReclusteringProgress)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Clusters/GetReclusteringProgress", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersClient) QueryClusterSummaries(ctx context.Context, in *QueryClusterSummariesRequest, opts ...grpc.CallOption) (*QueryClusterSummariesResponse, error) {
+	out := new(QueryClusterSummariesResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Clusters/QueryClusterSummaries", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *clustersClient) QueryClusterFailures(ctx context.Context, in *QueryClusterFailuresRequest, opts ...grpc.CallOption) (*QueryClusterFailuresResponse, error) {
+	out := new(QueryClusterFailuresResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Clusters/QueryClusterFailures", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ClustersServer is the server API for Clusters service.
+type ClustersServer interface {
+	// Identifies the cluster(s) for one or more test failure(s).
+	//
+	// This RPC returns the clusters of each test result, using
+	// current suggested cluster algorithms, configured failure
+	// association rules, and ingested project configuration with
+	// a bounded staleness of up to one minute. (Returned clusters
+	// may be based on project configuration and configured failure
+	// association rules that is up to one minute out-of-date).
+	//
+	// As at April 2022, the implementation does not use stale
+	// rules, but you are instructed NOT to rely on this property to
+	// allow reversion to the faster implementation that is tolerant
+	// to higher QPS in future. If your use case require strong reads
+	// (e.g. you want to call cluster immediately after updating a rule),
+	// please contact Weetbix owners. We may be able to provide a
+	// request flag to select this processing behaviour.
+	//
+	// This RPC is a pure query API and does not lead to the ingestion of the
+	// test failures by Weetbix (e.g. for cluster impact calculations).
+	Cluster(context.Context, *ClusterRequest) (*ClusterResponse, error)
+	// Reads information about the given clusters.
+	//
+	// Please consult Weetbix owners before adding additional calls to this
+	// RPC, as the implementation currently calls back to BigQuery and as
+	// such, is not cost-optimised if many queries are to be made.
+	//
+	// As of writing (April 13, 2022) this query reads ~1 GB per call for
+	// the largest LUCI Project, which translates to a cost of 0.5 US cents
+	// per query at published pricing (US$5/TB analyzed for BigQuery).
+	//
+	// Changes to this RPC should comply with https://google.aip.dev/231.
+	BatchGet(context.Context, *BatchGetClustersRequest) (*BatchGetClustersResponse, error)
+	// Reads current progress re-clustering the given project. Re-clustering
+	// means updating the clusters each failure is in to reflect the latest
+	// failure association rules, suggested clustering algorithms and
+	// clustering configuration.
+	GetReclusteringProgress(context.Context, *GetReclusteringProgressRequest) (*ReclusteringProgress, error)
+	// Queries summary information about top clusters.
+	//
+	// The set of test failures used as input to the clustering can be
+	// specified using the failure_filter field on the request.
+	// The returned clusters include only the impact derived from the
+	// filtered failures.
+	//
+	// This allows investigation of the highest impact clusters for some
+	// subset of the failure data in a project. For example, a filter string
+	// of "failure_reason:ssh" would find all of the clusters where any test
+	// results mention "ssh" in their failure reason, and show how big the
+	// impact from these ssh failures is in each cluster. This is useful when
+	// investigating specific problems, or ownership areas of the tests.
+	//
+	// Please consult Weetbix owners before adding additional calls to this
+	// RPC, as the implementation currently calls back to BigQuery and as
+	// such, is not cost-optimised if many queries are to be made.
+	//
+	// As of writing (April 13, 2022) this query reads up to 10 GB per call for
+	// the largest LUCI Project, which translates to a cost of up to 5 US cents
+	// per query at published pricing (US$5/TB analyzed for BigQuery).
+	QueryClusterSummaries(context.Context, *QueryClusterSummariesRequest) (*QueryClusterSummariesResponse, error)
+	// Queries examples of failures in the given cluster.
+	//
+	// Please consult Weetbix owners before adding additional calls to this
+	// RPC, as the implementation currently calls back to BigQuery and as
+	// such, is not cost-optimised if many queries are to be made.
+	QueryClusterFailures(context.Context, *QueryClusterFailuresRequest) (*QueryClusterFailuresResponse, error)
+}
+
+// UnimplementedClustersServer can be embedded to have forward compatible implementations.
+type UnimplementedClustersServer struct {
+}
+
+func (*UnimplementedClustersServer) Cluster(context.Context, *ClusterRequest) (*ClusterResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method Cluster not implemented")
+}
+func (*UnimplementedClustersServer) BatchGet(context.Context, *BatchGetClustersRequest) (*BatchGetClustersResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method BatchGet not implemented")
+}
+func (*UnimplementedClustersServer) GetReclusteringProgress(context.Context, *GetReclusteringProgressRequest) (*ReclusteringProgress, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetReclusteringProgress not implemented")
+}
+func (*UnimplementedClustersServer) QueryClusterSummaries(context.Context, *QueryClusterSummariesRequest) (*QueryClusterSummariesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryClusterSummaries not implemented")
+}
+func (*UnimplementedClustersServer) QueryClusterFailures(context.Context, *QueryClusterFailuresRequest) (*QueryClusterFailuresResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryClusterFailures not implemented")
+}
+
+func RegisterClustersServer(s prpc.Registrar, srv ClustersServer) {
+	s.RegisterService(&_Clusters_serviceDesc, srv)
+}
+
+func _Clusters_Cluster_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ClusterRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ClustersServer).Cluster(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Clusters/Cluster",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ClustersServer).Cluster(ctx, req.(*ClusterRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Clusters_BatchGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(BatchGetClustersRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ClustersServer).BatchGet(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Clusters/BatchGet",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ClustersServer).BatchGet(ctx, req.(*BatchGetClustersRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Clusters_GetReclusteringProgress_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetReclusteringProgressRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ClustersServer).GetReclusteringProgress(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Clusters/GetReclusteringProgress",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ClustersServer).GetReclusteringProgress(ctx, req.(*GetReclusteringProgressRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Clusters_QueryClusterSummaries_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryClusterSummariesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ClustersServer).QueryClusterSummaries(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Clusters/QueryClusterSummaries",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ClustersServer).QueryClusterSummaries(ctx, req.(*QueryClusterSummariesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Clusters_QueryClusterFailures_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryClusterFailuresRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ClustersServer).QueryClusterFailures(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Clusters/QueryClusterFailures",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ClustersServer).QueryClusterFailures(ctx, req.(*QueryClusterFailuresRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Clusters_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.v1.Clusters",
+	HandlerType: (*ClustersServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "Cluster",
+			Handler:    _Clusters_Cluster_Handler,
+		},
+		{
+			MethodName: "BatchGet",
+			Handler:    _Clusters_BatchGet_Handler,
+		},
+		{
+			MethodName: "GetReclusteringProgress",
+			Handler:    _Clusters_GetReclusteringProgress_Handler,
+		},
+		{
+			MethodName: "QueryClusterSummaries",
+			Handler:    _Clusters_QueryClusterSummaries_Handler,
+		},
+		{
+			MethodName: "QueryClusterFailures",
+			Handler:    _Clusters_QueryClusterFailures_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/proto/v1/clusters.proto",
+}
diff --git a/analysis/proto/v1/clusters.proto b/analysis/proto/v1/clusters.proto
new file mode 100644
index 0000000..66d3028
--- /dev/null
+++ b/analysis/proto/v1/clusters.proto
@@ -0,0 +1,494 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+import "go.chromium.org/luci/analysis/proto/v1/changelist.proto";
+import "go.chromium.org/luci/analysis/proto/v1/failure_reason.proto";
+
+// Provides methods to cluster test results, and obtain the impact of those
+// clusters.
+//
+// A cluster is a group of test failures with a common characteristic.
+// For example, test results may form a cluster with other failures that share
+// a common test name, or failure reason. Test results may also be in a cluster
+// defined by a user-modifiable failure association rule (which associates
+// failures with a bug). In this case, the failures have the property defined
+// by the failure association rule in common.
+//
+// A test result may be in many clusters, and each cluster may contain many
+// test results.
+//
+// Each cluster has an identity, consisting of three components:
+// - The LUCI Project name, e.g. "chromium" or "fuchsia".
+// - The Clustering Algorithm that identified the cluster. As at writing
+//   (April 2022), the algorithms are 'testname-v3' for version 3 of the
+//   test-name clustering algorithm, 'reason-v3' for version 3 of the failure
+//   reason clustering algorithm, and 'rules-v2' for the rules-based clustering
+//   algorithm.
+//   (Although internally versioned, the rules algorithm version is hidden
+//   for clients, so that {luci_project}/rules/{rule_id} always represents
+//   the cluster defined by the given rule_id.)
+//   We make no guarantees about the structure of algorithm names, they should
+//   be treated as opaque strings by clients.
+// - An algorithm-defined cluster identifier. This is algorithm-dependent and
+//   although (as at April 2022) a lowercase hexadecimal string, should be
+//   treated as an opaque value by clients.
+//   For the 'rules' algorithm, the cluster identifier will always correspond
+//   to the Rule ID of the rule that defines the cluster.
+service Clusters {
+    // Identifies the cluster(s) for one or more test failure(s).
+    //
+    // This RPC returns the clusters of each test result, using
+    // current suggested cluster algorithms, configured failure
+    // association rules, and ingested project configuration with
+    // a bounded staleness of up to one minute. (Returned clusters
+    // may be based on project configuration and configured failure
+    // association rules that is up to one minute out-of-date).
+    //
+    // As at April 2022, the implementation does not use stale
+    // rules, but you are instructed NOT to rely on this property to
+    // allow reversion to the faster implementation that is tolerant
+    // to higher QPS in future. If your use case require strong reads
+    // (e.g. you want to call cluster immediately after updating a rule),
+    // please contact Weetbix owners. We may be able to provide a
+    // request flag to select this processing behaviour.
+    //
+    // This RPC is a pure query API and does not lead to the ingestion of the
+    // test failures by Weetbix (e.g. for cluster impact calculations).
+    rpc Cluster(ClusterRequest) returns (ClusterResponse) {};
+
+    // Reads information about the given clusters.
+    //
+    // Please consult Weetbix owners before adding additional calls to this
+    // RPC, as the implementation currently calls back to BigQuery and as
+    // such, is not cost-optimised if many queries are to be made.
+    //
+    // As of writing (April 13, 2022) this query reads ~1 GB per call for
+    // the largest LUCI Project, which translates to a cost of 0.5 US cents
+    // per query at published pricing (US$5/TB analyzed for BigQuery).
+    //
+    // Changes to this RPC should comply with https://google.aip.dev/231.
+    rpc BatchGet(BatchGetClustersRequest)
+        returns (BatchGetClustersResponse) {};
+
+    // Reads current progress re-clustering the given project. Re-clustering
+    // means updating the clusters each failure is in to reflect the latest
+    // failure association rules, suggested clustering algorithms and
+    // clustering configuration.
+    rpc GetReclusteringProgress(GetReclusteringProgressRequest)
+        returns (ReclusteringProgress) {};
+
+    // Queries summary information about top clusters.
+    //
+    // The set of test failures used as input to the clustering can be
+    // specified using the failure_filter field on the request.
+    // The returned clusters include only the impact derived from the
+    // filtered failures.
+    //
+    // This allows investigation of the highest impact clusters for some
+    // subset of the failure data in a project. For example, a filter string
+    // of "failure_reason:ssh" would find all of the clusters where any test
+    // results mention "ssh" in their failure reason, and show how big the
+    // impact from these ssh failures is in each cluster. This is useful when
+    // investigating specific problems, or ownership areas of the tests.
+    //
+    // Please consult Weetbix owners before adding additional calls to this
+    // RPC, as the implementation currently calls back to BigQuery and as
+    // such, is not cost-optimised if many queries are to be made.
+    //
+    // As of writing (April 13, 2022) this query reads up to 10 GB per call for
+    // the largest LUCI Project, which translates to a cost of up to 5 US cents
+    // per query at published pricing (US$5/TB analyzed for BigQuery).
+    rpc QueryClusterSummaries(QueryClusterSummariesRequest)
+        returns (QueryClusterSummariesResponse) {};
+
+    // Queries examples of failures in the given cluster.
+    //
+    // Please consult Weetbix owners before adding additional calls to this
+    // RPC, as the implementation currently calls back to BigQuery and as
+    // such, is not cost-optimised if many queries are to be made.
+    rpc QueryClusterFailures(QueryClusterFailuresRequest)
+        returns (QueryClusterFailuresResponse) {};
+}
+
+message ClusterRequest {
+    // TestResult captures information about a test result, sufficient to
+    // cluster it. The fields requested here may be expanded over time.
+    // For example, variant information may be requested in future.
+    message TestResult {
+        // Opaque tag supplied by the caller, to be returned in the
+        // response. Provided to assist correlating responses with requests.
+        // Does not need to be unique. Optional.
+        string request_tag = 1;
+
+        // Identifier of the test (as reported to ResultDB).
+        // For chromium projects, this starts with ninja://.
+        string test_id = 2;
+
+        // The failure reason of the test (if any).
+        weetbix.v1.FailureReason failure_reason = 3;
+    }
+    // The LUCI Project for which the test result should be clustered.
+    string project = 1;
+
+    // The test results to cluster. At most 1000 test results may be
+    // clustered in one request.
+    repeated TestResult test_results = 2;
+}
+
+message ClusterResponse {
+    // The cluster(s) a test result is contained in.
+    message ClusteredTestResult {
+        // An individual cluster a test result is contained in.
+        message ClusterEntry {
+            // The unique identifier of the cluster.
+            // If the algorithm is "rules", the cluster ID is also a rule ID.
+            weetbix.v1.ClusterId cluster_id = 1;
+
+            // The bug associated with the cluster, if any. This is only
+            // populated for clusters defined by a failure association rule,
+            // which associates specified failures to a bug.
+            weetbix.v1.AssociatedBug bug = 2;
+        }
+        // Opaque tag supplied by the caller in the request. Provided to assist
+        // the caller correlate responses with requests.
+        string request_tag = 1;
+
+        // The clusters the test result is contained within.
+        repeated ClusterEntry clusters = 2;
+    }
+
+   // The clusters each test result is in.
+   // Contains one result for each test result specified in the request.
+   // Results are provided in the same order as the request, so
+   // the i-th ClusteredTestResult corresponds to the i-th
+   // TestResult in the request.
+   repeated ClusteredTestResult clustered_test_results = 1;
+
+   // The versions of clustering algorithms, rules and project configuration
+   // used to service this request. For debugging purposes only.
+   ClusteringVersion clustering_version = 2;
+}
+
+// The versions of algorithms, rules and configuration used by Weetbix
+// to cluster test results. For a given test result and ClusteringVersion,
+// the set of returned clusters should always be the same.
+message ClusteringVersion {
+    // The version of clustering algorithms used.
+    int32 algorithms_version = 1;
+
+    // The version of failure association rules used. This is the Spanner
+    // commit timestamp of the last rule modification incorporated in the
+    // set of rules used to cluster the results.
+    google.protobuf.Timestamp rules_version = 2;
+
+    // The version of project configuration used. This is the timestamp
+    // the project configuration was ingested by Weetbix.
+    google.protobuf.Timestamp config_version = 3;
+}
+
+message BatchGetClustersRequest {
+    // The LUCI project shared by all clusters to retrieve.
+    // Required.
+    // Format: projects/{project}.
+    string parent = 1;
+
+    // The resource name of the clusters retrieve.
+    // Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}.
+    // Designed to conform to aip.dev/231.
+    // At most 1,000 clusters may be requested at a time.
+    repeated string names = 2;
+}
+
+message BatchGetClustersResponse {
+    // The clusters requested.
+    repeated Cluster clusters = 1;
+}
+
+message Cluster {
+    // The resource name of the cluster.
+    // Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}.
+    string name = 1;
+
+    // Whether there is a recent example in the cluster.
+    bool has_example = 2;
+
+    // A human-readable name for the cluster.
+    // Only populated for suggested clusters where has_example = true.
+    // Not populated for rule-based clusters.
+    string title = 3;
+
+    message MetricValues {
+        message Counts {
+            // The value of the metric (summed over all failures).
+            int64 nominal = 1;
+        }
+
+        // The metric value for the last day.
+        Counts one_day = 2;
+        // The metric value for the last three days.
+        Counts three_day = 3;
+        // The metric value for the last week.
+        Counts seven_day = 4;
+    }
+
+    // The total number of user changelists which failed presubmit.
+    MetricValues user_cls_failed_presubmit = 4;
+
+    // The total number of failures in the cluster that occurred on tryjobs
+    // that were critical (presubmit-blocking) and were exonerated for a
+    // reason other than NOT_CRITICAL or UNEXPECTED_PASS.
+    MetricValues critical_failures_exonerated = 5;
+
+    // The total number of failures in the cluster.
+    MetricValues failures = 6;
+
+    // The failure association rule equivalent to the cluster. Populated only
+    // for suggested clusters where has_example = true.
+    // Not populated for rule-based clusters. If you need the failure
+    // association rule for a rule-based cluster, use weetbix.v1.Rules/Get
+    // to retrieve the rule with ID matching the cluster ID.
+    // Used to facilitate creating a new rule based on a suggested cluster.
+    string equivalent_failure_association_rule = 7;
+}
+
+// Designed to conform with aip.dev/131.
+message GetReclusteringProgressRequest {
+    // The name of the reclustering progress resource to retrieve.
+    // Format: projects/{project}/reclusteringProgress.
+    string name = 1;
+}
+
+// ReclusteringProgress captures the progress re-clustering a
+// given LUCI project's test results using specific rules
+// versions or algorithms versions.
+message ReclusteringProgress {
+    // The name of the reclustering progress resource.
+    // Format: projects/{project}/reclusteringProgress.
+    string name = 1;
+
+    // ProgressPerMille is the progress of the current re-clustering run,
+    // measured in thousandths (per mille). As such, this value ranges
+    // from 0 (0% complete) to 1000 (100% complete).
+    int32 progress_per_mille = 2;
+
+    // The goal of the last completed re-clustering run.
+    ClusteringVersion last = 5;
+
+    // The goal of the current re-clustering run. (For which
+    // ProgressPerMille is specified.) This may be the same as the
+    // last completed re-clustering run the available algorithm versions,
+    // rules and configuration is unchanged.
+    ClusteringVersion next = 6;
+}
+
+message QueryClusterSummariesRequest {
+    // The LUCI Project.
+    string project = 1;
+
+    // An AIP-160 style filter to select test failures in the project
+    // to cluster and calculate metrics for.
+    //
+    // Filtering supports a subset of [AIP-160 filtering](https://google.aip.dev/160).
+    //
+    // All values are case-sensitive.
+    //
+    // A bare value is searched for in the columns test_id and
+    // failure_reason. E.g. ninja or "test failed".
+    //
+    // You can use AND, OR and NOT (case sensitive) logical operators, along
+    // with grouping. '-' is equivalent to NOT. Multiple bare values are
+    // considered to be AND separated.  E.g. These are equivalent:
+    // hello world
+    // and:
+    // hello AND world
+    //
+    // More examples:
+    // a OR b
+    // a AND NOT(b or -c)
+    //
+    // You can filter particular columns with '=', '!=' and ':' (has) operators.
+    // The right hand side of the operator must be a simple value. E.g:
+    // test_id:telemetry
+    // -failure_reason:Timeout
+    // ingested_invocation_id="build-8822963500388678513"
+    //
+    // Supported columns to search on:
+    // - test_id
+    // - failure_reason
+    // - realm
+    // - ingested_invocation_id
+    // - cluster_algorithm
+    // - cluster_id
+    // - variant_hash
+    // - test_run_id
+    string failure_filter = 2;
+
+    // A comma-seperated list of fields to order the response by.
+    // Valid fields are "presubmit_rejects", "critical_failures_exonerated"
+    // and "failures".
+    // The default sorting order is ascending, to specify descending order
+    // for a field append a " desc" suffix.
+    // E.g. "presubmit_rejects desc, failures desc".
+    // See the order by section on aip.dev/132 for more details.
+    string order_by = 3;
+}
+
+message QueryClusterSummariesResponse {
+    // The clusters and impact metrics from the filtered failures.
+    repeated ClusterSummary cluster_summaries = 1;
+}
+
+message ClusterSummary {
+    // The cluster ID of this cluster.
+    weetbix.v1.ClusterId cluster_id = 1;
+
+    // Title is a one-line description of the cluster.
+    string title = 2;
+
+    // The bug associated with the cluster. This will only be present for
+    // rules algorithm clusters.
+    weetbix.v1.AssociatedBug bug = 3;
+
+    // The number of distinct developer changelists that failed at least one
+    // presubmit (CQ) run because of failure(s) in this cluster.
+    int64 presubmit_rejects = 4;
+
+    // The number of failures on test variants which were configured to be
+    // presubmit-blocking, which were exonerated (i.e. did not actually block
+    // presubmit) because infrastructure determined the test variant to be
+    // failing or too flaky at tip-of-tree. If this number is non-zero, it
+    // means a test variant which was configured to be presubmit-blocking is
+    // not stable enough to do so, and should be fixed or made non-blocking.
+    int64 critical_failures_exonerated = 5;
+
+    // The total number of test results in this cluster. Weetbix only
+    // clusters test results which are unexpected and have a status of crash,
+    // abort or fail.
+    int64 failures = 6;
+}
+
+message QueryClusterFailuresRequest {
+    // The resource name of the cluster to retrieve failures for.
+    // Format: projects/{project}/clusters/{cluster_algorithm}/{cluster_id}/failures.
+    string parent = 1;
+}
+
+message QueryClusterFailuresResponse {
+    // Example failures in the cluster.
+    // Limited to the most recent 2000 examples.
+    repeated DistinctClusterFailure failures = 1;
+}
+
+// DistinctClusterFailure represents a number of failures which have identical
+// properties. This provides slightly compressed transfer of examples.
+message DistinctClusterFailure {
+    // Representation of an exoneration. An exoneration means the subject of
+    // the test (e.g. a CL) is absolved from blame for the unexpected results
+    // of the test variant.
+    message Exoneration {
+        // The machine-readable reason for the exoneration.
+        weetbix.v1.ExonerationReason reason = 1;
+    }
+    
+    // Representation of a presubmit run (e.g. LUCI CV Run).
+    message PresubmitRun {
+        // Identity of the presubmit run that contains this test result.
+        // This should be unique per "CQ+1"/"CQ+2" attempt on gerrit.
+        //
+        // One presumbit run MAY have many ingested invocation IDs (e.g. for its
+        // various tryjobs), but every ingested invocation ID only ever has one
+        // presubmit run ID (if any).
+        //
+        // All test results for the same presubmit run will have one
+        // partition_time.
+        //
+        // If the test result was not collected as part of a presubmit run,
+        // this is unset.
+        weetbix.v1.PresubmitRunId presubmit_run_id = 1;
+    
+        // The owner of the presubmit run (if any).
+        // This is the owner of the CL on which CQ+1/CQ+2 was clicked
+        // (even in case of presubmit run with multiple CLs).
+        // There is scope for this field to become an email address if privacy
+        // approval is obtained, until then it is "automation" (for automation
+        // service accounts) and "user" otherwise.
+        string owner = 2;
+    
+        // The mode of the presubmit run. E.g. DRY_RUN, FULL_RUN, QUICK_DRY_RUN.
+        weetbix.v1.PresubmitRunMode mode = 3;
+    }
+
+    // The identity of the test.
+    string test_id = 1;
+
+    // The test variant.
+    weetbix.v1.Variant variant = 2;
+
+    // Timestamp representing the start of the data retention period for the
+    // test results in this group.
+    // The partition time is usually the presubmit run start time (if any) or
+    // build start time.
+    google.protobuf.Timestamp partition_time = 3;
+
+    // Details if the presubmit run associated with these results (if any).
+    PresubmitRun presubmit_run = 4;
+
+    // Whether the build was critical to a presubmit run succeeding.
+    // If the build was not part of a presubmit run, this field should
+    // be ignored.
+    bool is_build_critical = 5;
+
+    // The exonerations applied to the test variant verdict.
+    repeated Exoneration exonerations = 6;
+
+    // The status of the build that contained this test result. Can be used
+    // to filter incomplete results (e.g. where build was cancelled or had
+    // an infra failure). Can also be used to filter builds with incomplete
+    // exonerations (e.g. build succeeded but some tests not exonerated).
+    // This is the build corresponding to ingested_invocation_id.
+    weetbix.v1.BuildStatus build_status = 7;
+
+    // The invocation from which this test result was ingested. This is
+    // the top-level invocation that was ingested, an "invocation" being
+    // a container of test results as identified by the source test result
+    // system.
+    //
+    // For ResultDB, Weetbix ingests invocations corresponding to
+    // buildbucket builds.
+    string ingested_invocation_id = 8;
+
+    // Is the ingested invocation blocked by this test variant? This is
+    // only true if all (non-skipped) test results for this test variant
+    // (in the ingested invocation) are unexpected failures.
+    //
+    // Exoneration does not factor into this value; check exonerations
+    // to see if the impact of this ingested invocation being blocked was
+    // mitigated by exoneration. 
+    bool is_ingested_invocation_blocked = 9;
+
+    // The unsubmitted changelists that were tested (if any).
+    // Up to 10 changelists are captured.
+    repeated weetbix.v1.Changelist changelists = 10;
+
+    // The number of test results which have these properties.
+    int32 count = 11;
+}
diff --git a/analysis/proto/v1/clustersserver_dec.go b/analysis/proto/v1/clustersserver_dec.go
new file mode 100644
index 0000000..0ed9b5a
--- /dev/null
+++ b/analysis/proto/v1/clustersserver_dec.go
@@ -0,0 +1,109 @@
+// Code generated by svcdec; DO NOT EDIT.
+
+package weetbixpb
+
+import (
+	"context"
+
+	proto "github.com/golang/protobuf/proto"
+)
+
+type DecoratedClusters struct {
+	// Service is the service to decorate.
+	Service ClustersServer
+	// Prelude is called for each method before forwarding the call to Service.
+	// If Prelude returns an error, then the call is skipped and the error is
+	// processed via the Postlude (if one is defined), or it is returned directly.
+	Prelude func(ctx context.Context, methodName string, req proto.Message) (context.Context, error)
+	// Postlude is called for each method after Service has processed the call, or
+	// after the Prelude has returned an error. This takes the the Service's
+	// response proto (which may be nil) and/or any error. The decorated
+	// service will return the response (possibly mutated) and error that Postlude
+	// returns.
+	Postlude func(ctx context.Context, methodName string, rsp proto.Message, err error) error
+}
+
+func (s *DecoratedClusters) Cluster(ctx context.Context, req *ClusterRequest) (rsp *ClusterResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "Cluster", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.Cluster(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "Cluster", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedClusters) BatchGet(ctx context.Context, req *BatchGetClustersRequest) (rsp *BatchGetClustersResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "BatchGet", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.BatchGet(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "BatchGet", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedClusters) GetReclusteringProgress(ctx context.Context, req *GetReclusteringProgressRequest) (rsp *ReclusteringProgress, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "GetReclusteringProgress", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.GetReclusteringProgress(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "GetReclusteringProgress", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedClusters) QueryClusterSummaries(ctx context.Context, req *QueryClusterSummariesRequest) (rsp *QueryClusterSummariesResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "QueryClusterSummaries", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.QueryClusterSummaries(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "QueryClusterSummaries", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedClusters) QueryClusterFailures(ctx context.Context, req *QueryClusterFailuresRequest) (rsp *QueryClusterFailuresResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "QueryClusterFailures", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.QueryClusterFailures(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "QueryClusterFailures", rsp, err)
+	}
+	return
+}
diff --git a/analysis/proto/v1/common.pb.go b/analysis/proto/v1/common.pb.go
new file mode 100644
index 0000000..cee163f
--- /dev/null
+++ b/analysis/proto/v1/common.pb.go
@@ -0,0 +1,1312 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/common.proto
+
+package weetbixpb
+
+import (
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// BuildStatus the result of the build in which the test verdict was produced.
+// This can be used to detect if the test verdict is incomplete (e.g. because
+// an infra failure or cancellation occurred), and whether the unexpected
+// test verdict was also followed by a failing build.
+//
+// Note: All values prefixed with BUILD_STATUS_ as the names are generic
+// and likely to conflict with other/future enumerations otherwise.
+// See https://google.aip.dev/126.
+type BuildStatus int32
+
+const (
+	// A build must not have this status.
+	BuildStatus_BUILD_STATUS_UNSPECIFIED BuildStatus = 0
+	// The build succeeded.
+	BuildStatus_BUILD_STATUS_SUCCESS BuildStatus = 1
+	// The build failed.
+	BuildStatus_BUILD_STATUS_FAILURE BuildStatus = 2
+	// The build encountered an infrastructure failure.
+	BuildStatus_BUILD_STATUS_INFRA_FAILURE BuildStatus = 3
+	// The build was canceled.
+	BuildStatus_BUILD_STATUS_CANCELED BuildStatus = 4
+)
+
+// Enum value maps for BuildStatus.
+var (
+	BuildStatus_name = map[int32]string{
+		0: "BUILD_STATUS_UNSPECIFIED",
+		1: "BUILD_STATUS_SUCCESS",
+		2: "BUILD_STATUS_FAILURE",
+		3: "BUILD_STATUS_INFRA_FAILURE",
+		4: "BUILD_STATUS_CANCELED",
+	}
+	BuildStatus_value = map[string]int32{
+		"BUILD_STATUS_UNSPECIFIED":   0,
+		"BUILD_STATUS_SUCCESS":       1,
+		"BUILD_STATUS_FAILURE":       2,
+		"BUILD_STATUS_INFRA_FAILURE": 3,
+		"BUILD_STATUS_CANCELED":      4,
+	}
+)
+
+func (x BuildStatus) Enum() *BuildStatus {
+	p := new(BuildStatus)
+	*p = x
+	return p
+}
+
+func (x BuildStatus) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (BuildStatus) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[0].Descriptor()
+}
+
+func (BuildStatus) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[0]
+}
+
+func (x BuildStatus) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use BuildStatus.Descriptor instead.
+func (BuildStatus) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{0}
+}
+
+// ExonerationReason captures a reason why a test failure was
+// exonerated. Exonerated means the failure was ignored and did not
+// have further impact, in terms of causing the build to fail or
+// rejecting the CL being tested in a presubmit run.
+//
+// Based on https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/resultdb/proto/v1/test_result.proto?q=ExonerationReason&type=cs.
+type ExonerationReason int32
+
+const (
+	// A test failure must not have this status.
+	ExonerationReason_EXONERATION_REASON_UNSPECIFIED ExonerationReason = 0
+	// Similar unexpected results were observed on a mainline branch
+	// (i.e. against a build without unsubmitted changes applied).
+	// (For avoidance of doubt, this includes both flakily and
+	// deterministically occurring unexpected results.)
+	// Applies to unexpected results in presubmit/CQ runs only.
+	ExonerationReason_OCCURS_ON_MAINLINE ExonerationReason = 1
+	// Similar unexpected results were observed in presubmit run(s) for other,
+	// unrelated CL(s). (This is suggestive of the issue being present
+	// on mainline but is not confirmed as there are possible confounding
+	// factors, like how tests are run on CLs vs how tests are run on
+	// mainline branches.)
+	// Applies to unexpected results in presubmit/CQ runs only.
+	ExonerationReason_OCCURS_ON_OTHER_CLS ExonerationReason = 2
+	// The tests are not critical to the test subject (e.g. CL) passing.
+	// This could be because more data is being collected to determine if
+	// the tests are stable enough to be made critical (as is often the
+	// case for experimental test suites).
+	ExonerationReason_NOT_CRITICAL ExonerationReason = 3
+	// The test result was an unexpected pass. (Note that such an exoneration is
+	// not automatically created for unexpected passes, unless the option is
+	// specified to ResultSink or the project manually creates one).
+	ExonerationReason_UNEXPECTED_PASS ExonerationReason = 4
+)
+
+// Enum value maps for ExonerationReason.
+var (
+	ExonerationReason_name = map[int32]string{
+		0: "EXONERATION_REASON_UNSPECIFIED",
+		1: "OCCURS_ON_MAINLINE",
+		2: "OCCURS_ON_OTHER_CLS",
+		3: "NOT_CRITICAL",
+		4: "UNEXPECTED_PASS",
+	}
+	ExonerationReason_value = map[string]int32{
+		"EXONERATION_REASON_UNSPECIFIED": 0,
+		"OCCURS_ON_MAINLINE":             1,
+		"OCCURS_ON_OTHER_CLS":            2,
+		"NOT_CRITICAL":                   3,
+		"UNEXPECTED_PASS":                4,
+	}
+)
+
+func (x ExonerationReason) Enum() *ExonerationReason {
+	p := new(ExonerationReason)
+	*p = x
+	return p
+}
+
+func (x ExonerationReason) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ExonerationReason) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[1].Descriptor()
+}
+
+func (ExonerationReason) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[1]
+}
+
+func (x ExonerationReason) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ExonerationReason.Descriptor instead.
+func (ExonerationReason) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{1}
+}
+
+// SubmittedFilter filters test verdicts based on whether they had unsubmitted
+// changes.
+type SubmittedFilter int32
+
+const (
+	// Default value. Include all test verdicts.
+	SubmittedFilter_SUBMITTED_FILTER_UNSPECIFIED SubmittedFilter = 0
+	// Only include test verdicts that don't have unsubmitted changes.
+	SubmittedFilter_ONLY_SUBMITTED SubmittedFilter = 1
+	// Only include test verdicts that have unsubmitted changes.
+	SubmittedFilter_ONLY_UNSUBMITTED SubmittedFilter = 2
+)
+
+// Enum value maps for SubmittedFilter.
+var (
+	SubmittedFilter_name = map[int32]string{
+		0: "SUBMITTED_FILTER_UNSPECIFIED",
+		1: "ONLY_SUBMITTED",
+		2: "ONLY_UNSUBMITTED",
+	}
+	SubmittedFilter_value = map[string]int32{
+		"SUBMITTED_FILTER_UNSPECIFIED": 0,
+		"ONLY_SUBMITTED":               1,
+		"ONLY_UNSUBMITTED":             2,
+	}
+)
+
+func (x SubmittedFilter) Enum() *SubmittedFilter {
+	p := new(SubmittedFilter)
+	*p = x
+	return p
+}
+
+func (x SubmittedFilter) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (SubmittedFilter) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[2].Descriptor()
+}
+
+func (SubmittedFilter) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[2]
+}
+
+func (x SubmittedFilter) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use SubmittedFilter.Descriptor instead.
+func (SubmittedFilter) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{2}
+}
+
+// PresubmitRunMode describes the mode of a presubmit run. Currently
+// based on LUCI CV run mode enumeration at
+// https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/cv/api/bigquery/v1/attempt.proto?q=QUICK_DRY_RUN&type=cs.
+type PresubmitRunMode int32
+
+const (
+	// A presubmit run must not have this status.
+	PresubmitRunMode_PRESUBMIT_RUN_MODE_UNSPECIFIED PresubmitRunMode = 0
+	// Run all tests but do not submit.
+	PresubmitRunMode_DRY_RUN PresubmitRunMode = 1
+	// Run all tests and potentially submit.
+	PresubmitRunMode_FULL_RUN PresubmitRunMode = 2
+	// Run some tests but do not submit.
+	PresubmitRunMode_QUICK_DRY_RUN PresubmitRunMode = 3
+)
+
+// Enum value maps for PresubmitRunMode.
+var (
+	PresubmitRunMode_name = map[int32]string{
+		0: "PRESUBMIT_RUN_MODE_UNSPECIFIED",
+		1: "DRY_RUN",
+		2: "FULL_RUN",
+		3: "QUICK_DRY_RUN",
+	}
+	PresubmitRunMode_value = map[string]int32{
+		"PRESUBMIT_RUN_MODE_UNSPECIFIED": 0,
+		"DRY_RUN":                        1,
+		"FULL_RUN":                       2,
+		"QUICK_DRY_RUN":                  3,
+	}
+)
+
+func (x PresubmitRunMode) Enum() *PresubmitRunMode {
+	p := new(PresubmitRunMode)
+	*p = x
+	return p
+}
+
+func (x PresubmitRunMode) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (PresubmitRunMode) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[3].Descriptor()
+}
+
+func (PresubmitRunMode) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[3]
+}
+
+func (x PresubmitRunMode) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use PresubmitRunMode.Descriptor instead.
+func (PresubmitRunMode) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{3}
+}
+
+// PresubmitRunStatus is the ending status of a presubmit run.
+//
+// Note: All values prefixed with PRESUBMIT_RUN_STATUS_ as the names are
+// generic and likely to conflict with other/future enumerations otherwise.
+// See https://google.aip.dev/126.
+//
+// Based on https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/cv/internal/run/storage.proto;l=28?q=LUCI%20CV%20status%20lang:proto.
+type PresubmitRunStatus int32
+
+const (
+	// A build must not have this status.
+	PresubmitRunStatus_PRESUBMIT_RUN_STATUS_UNSPECIFIED PresubmitRunStatus = 0
+	// The run succeeded.
+	PresubmitRunStatus_PRESUBMIT_RUN_STATUS_SUCCEEDED PresubmitRunStatus = 1
+	// The run failed.
+	PresubmitRunStatus_PRESUBMIT_RUN_STATUS_FAILED PresubmitRunStatus = 2
+	// The run was canceled.
+	PresubmitRunStatus_PRESUBMIT_RUN_STATUS_CANCELED PresubmitRunStatus = 3
+)
+
+// Enum value maps for PresubmitRunStatus.
+var (
+	PresubmitRunStatus_name = map[int32]string{
+		0: "PRESUBMIT_RUN_STATUS_UNSPECIFIED",
+		1: "PRESUBMIT_RUN_STATUS_SUCCEEDED",
+		2: "PRESUBMIT_RUN_STATUS_FAILED",
+		3: "PRESUBMIT_RUN_STATUS_CANCELED",
+	}
+	PresubmitRunStatus_value = map[string]int32{
+		"PRESUBMIT_RUN_STATUS_UNSPECIFIED": 0,
+		"PRESUBMIT_RUN_STATUS_SUCCEEDED":   1,
+		"PRESUBMIT_RUN_STATUS_FAILED":      2,
+		"PRESUBMIT_RUN_STATUS_CANCELED":    3,
+	}
+)
+
+func (x PresubmitRunStatus) Enum() *PresubmitRunStatus {
+	p := new(PresubmitRunStatus)
+	*p = x
+	return p
+}
+
+func (x PresubmitRunStatus) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (PresubmitRunStatus) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[4].Descriptor()
+}
+
+func (PresubmitRunStatus) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes[4]
+}
+
+func (x PresubmitRunStatus) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use PresubmitRunStatus.Descriptor instead.
+func (PresubmitRunStatus) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{4}
+}
+
+// A range of timestamps.
+type TimeRange struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The oldest timestamp to include in the range.
+	Earliest *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=earliest,proto3" json:"earliest,omitempty"`
+	// Include only timestamps that are strictly older than this.
+	Latest *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=latest,proto3" json:"latest,omitempty"`
+}
+
+func (x *TimeRange) Reset() {
+	*x = TimeRange{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TimeRange) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TimeRange) ProtoMessage() {}
+
+func (x *TimeRange) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TimeRange.ProtoReflect.Descriptor instead.
+func (*TimeRange) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *TimeRange) GetEarliest() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Earliest
+	}
+	return nil
+}
+
+func (x *TimeRange) GetLatest() *timestamppb.Timestamp {
+	if x != nil {
+		return x.Latest
+	}
+	return nil
+}
+
+// Identity of a test result.
+type TestResultId struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The test results system.
+	// Currently, the only valid value is "resultdb".
+	System string `protobuf:"bytes,1,opt,name=system,proto3" json:"system,omitempty"`
+	// ID for the test result in the test results system.
+	// For test results in ResultDB, the format is:
+	// "invocations/{INVOCATION_ID}/tests/{URL_ESCAPED_TEST_ID}/results/{RESULT_ID}"
+	// Where INVOCATION_ID, URL_ESCAPED_TEST_ID and RESULT_ID are values defined
+	// in ResultDB.
+	Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+}
+
+func (x *TestResultId) Reset() {
+	*x = TestResultId{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestResultId) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestResultId) ProtoMessage() {}
+
+func (x *TestResultId) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestResultId.ProtoReflect.Descriptor instead.
+func (*TestResultId) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *TestResultId) GetSystem() string {
+	if x != nil {
+		return x.System
+	}
+	return ""
+}
+
+func (x *TestResultId) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+// Variant represents a way of running a test case.
+//
+// The same test case can be executed in different ways, for example on
+// different OS, GPUs, with different compile options or runtime flags.
+type Variant struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The definition of the variant. Each key-value pair represents a
+	// parameter describing how the test was run (e.g. OS, GPU, etc.).
+	Def map[string]string `protobuf:"bytes,1,rep,name=def,proto3" json:"def,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
+}
+
+func (x *Variant) Reset() {
+	*x = Variant{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Variant) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Variant) ProtoMessage() {}
+
+func (x *Variant) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Variant.ProtoReflect.Descriptor instead.
+func (*Variant) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *Variant) GetDef() map[string]string {
+	if x != nil {
+		return x.Def
+	}
+	return nil
+}
+
+type StringPair struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Regex: ^[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)*$
+	// Max length: 64.
+	Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"`
+	// Max length: 256.
+	Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
+}
+
+func (x *StringPair) Reset() {
+	*x = StringPair{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *StringPair) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*StringPair) ProtoMessage() {}
+
+func (x *StringPair) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use StringPair.ProtoReflect.Descriptor instead.
+func (*StringPair) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *StringPair) GetKey() string {
+	if x != nil {
+		return x.Key
+	}
+	return ""
+}
+
+func (x *StringPair) GetValue() string {
+	if x != nil {
+		return x.Value
+	}
+	return ""
+}
+
+// Identity of a bug tracking component in a bug tracking system.
+type BugTrackingComponent struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The bug tracking system corresponding to this test case, as identified
+	// by the test results system.
+	// Currently, the only valid value is "monorail".
+	System string `protobuf:"bytes,1,opt,name=system,proto3" json:"system,omitempty"`
+	// The bug tracking component corresponding to this test case, as identified
+	// by the test results system.
+	// If the bug tracking system is monorail, this is the component as the
+	// user would see it, e.g. "Infra>Test>Flakiness". For monorail, the bug
+	// tracking project (e.g. "chromium") is not encoded, but assumed to be
+	// specified in the project's Weetbix configuration.
+	Component string `protobuf:"bytes,2,opt,name=component,proto3" json:"component,omitempty"`
+}
+
+func (x *BugTrackingComponent) Reset() {
+	*x = BugTrackingComponent{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *BugTrackingComponent) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*BugTrackingComponent) ProtoMessage() {}
+
+func (x *BugTrackingComponent) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use BugTrackingComponent.ProtoReflect.Descriptor instead.
+func (*BugTrackingComponent) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *BugTrackingComponent) GetSystem() string {
+	if x != nil {
+		return x.System
+	}
+	return ""
+}
+
+func (x *BugTrackingComponent) GetComponent() string {
+	if x != nil {
+		return x.Component
+	}
+	return ""
+}
+
+// Identity of a presubmit run (also known as a "CQ Run" or "CV Run").
+type PresubmitRunId struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The system that was used to process the presubmit run.
+	// Currently, the only valid value is "luci-cv" for LUCI Commit Verifier
+	// (LUCI CV).
+	System string `protobuf:"bytes,1,opt,name=system,proto3" json:"system,omitempty"`
+	// Identity of the presubmit run.
+	// If the presubmit system is LUCI CV, the format of this value is:
+	//   "{LUCI_PROJECT}/{LUCI_CV_ID}", e.g.
+	//   "infra/8988819463854-1-f94732fe20056fd1".
+	Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+}
+
+func (x *PresubmitRunId) Reset() {
+	*x = PresubmitRunId{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *PresubmitRunId) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*PresubmitRunId) ProtoMessage() {}
+
+func (x *PresubmitRunId) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use PresubmitRunId.ProtoReflect.Descriptor instead.
+func (*PresubmitRunId) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *PresubmitRunId) GetSystem() string {
+	if x != nil {
+		return x.System
+	}
+	return ""
+}
+
+func (x *PresubmitRunId) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+// Identity of a bug in a bug-tracking system.
+type AssociatedBug struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// System is the bug tracking system of the bug. This is either
+	// "monorail" or "buganizer".
+	System string `protobuf:"bytes,1,opt,name=system,proto3" json:"system,omitempty"`
+	// Id is the bug tracking system-specific identity of the bug.
+	// For monorail, the scheme is {project}/{numeric_id}, for
+	// buganizer the scheme is {numeric_id}.
+	Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+	// A human-readable name for the bug. This is typically the
+	// bug shortlink (e.g. "crbug.com/1234567").
+	LinkText string `protobuf:"bytes,3,opt,name=link_text,json=linkText,proto3" json:"link_text,omitempty"`
+	// The resolved bug URL, e.g.
+	// E.g. "https://bugs.chromium.org/p/chromium/issues/detail?id=123456".
+	Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"`
+}
+
+func (x *AssociatedBug) Reset() {
+	*x = AssociatedBug{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *AssociatedBug) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AssociatedBug) ProtoMessage() {}
+
+func (x *AssociatedBug) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use AssociatedBug.ProtoReflect.Descriptor instead.
+func (*AssociatedBug) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *AssociatedBug) GetSystem() string {
+	if x != nil {
+		return x.System
+	}
+	return ""
+}
+
+func (x *AssociatedBug) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+func (x *AssociatedBug) GetLinkText() string {
+	if x != nil {
+		return x.LinkText
+	}
+	return ""
+}
+
+func (x *AssociatedBug) GetUrl() string {
+	if x != nil {
+		return x.Url
+	}
+	return ""
+}
+
+// ClusterId represents the identity of a cluster. The LUCI Project is
+// omitted as it is assumed to be implicit from the context.
+//
+// This is often used in place of the resource name of the cluster
+// (in the sense of https://google.aip.dev/122) as clients may need
+// to access individual parts of the resource name (e.g. to determine
+// the algorithm used) and it is not desirable to make clients parse
+// the resource name.
+type ClusterId struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Algorithm is the name of the clustering algorithm that identified
+	// the cluster.
+	Algorithm string `protobuf:"bytes,1,opt,name=algorithm,proto3" json:"algorithm,omitempty"`
+	// Id is the cluster identifier returned by the algorithm. The underlying
+	// identifier is at most 16 bytes, but is represented here as a hexadecimal
+	// string of up to 32 lowercase hexadecimal characters.
+	Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+}
+
+func (x *ClusterId) Reset() {
+	*x = ClusterId{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ClusterId) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ClusterId) ProtoMessage() {}
+
+func (x *ClusterId) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ClusterId.ProtoReflect.Descriptor instead.
+func (*ClusterId) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *ClusterId) GetAlgorithm() string {
+	if x != nil {
+		return x.Algorithm
+	}
+	return ""
+}
+
+func (x *ClusterId) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+// Information about a test.
+//
+// As of Oct 2021, it's an exact copy of luci.resultdb.v1.TestMetadata, but
+// we'd like to keep a local definition of the proto to keep the possibility that
+// we need to diverge down the track.
+type TestMetadata struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The original test name.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Where the test is defined, e.g. the file name.
+	// location.repo MUST be specified.
+	Location *TestLocation `protobuf:"bytes,2,opt,name=location,proto3" json:"location,omitempty"`
+}
+
+func (x *TestMetadata) Reset() {
+	*x = TestMetadata{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestMetadata) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestMetadata) ProtoMessage() {}
+
+func (x *TestMetadata) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestMetadata.ProtoReflect.Descriptor instead.
+func (*TestMetadata) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *TestMetadata) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *TestMetadata) GetLocation() *TestLocation {
+	if x != nil {
+		return x.Location
+	}
+	return nil
+}
+
+// Location of the test definition.
+type TestLocation struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Gitiles URL as the identifier for a repo.
+	// Format for Gitiles URL: https://<host>/<project>
+	// For example "https://chromium.googlesource.com/chromium/src"
+	// Must not end with ".git".
+	// SHOULD be specified.
+	Repo string `protobuf:"bytes,1,opt,name=repo,proto3" json:"repo,omitempty"`
+	// Name of the file where the test is defined.
+	// For files in a repository, must start with "//"
+	// Example: "//components/payments/core/payment_request_data_util_unittest.cc"
+	// Max length: 512.
+	// MUST not use backslashes.
+	// Required.
+	FileName string `protobuf:"bytes,2,opt,name=file_name,json=fileName,proto3" json:"file_name,omitempty"`
+	// One-based line number where the test is defined.
+	Line int32 `protobuf:"varint,3,opt,name=line,proto3" json:"line,omitempty"`
+}
+
+func (x *TestLocation) Reset() {
+	*x = TestLocation{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestLocation) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestLocation) ProtoMessage() {}
+
+func (x *TestLocation) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestLocation.ProtoReflect.Descriptor instead.
+func (*TestLocation) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *TestLocation) GetRepo() string {
+	if x != nil {
+		return x.Repo
+	}
+	return ""
+}
+
+func (x *TestLocation) GetFileName() string {
+	if x != nil {
+		return x.FileName
+	}
+	return ""
+}
+
+func (x *TestLocation) GetLine() int32 {
+	if x != nil {
+		return x.Line
+	}
+	return 0
+}
+
+var File_infra_appengine_weetbix_proto_v1_common_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_common_proto_rawDesc = []byte{
+	0x0a, 0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12,
+	0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65,
+	0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69,
+	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x77, 0x0a,
+	0x09, 0x54, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x12, 0x36, 0x0a, 0x08, 0x65, 0x61,
+	0x72, 0x6c, 0x69, 0x65, 0x73, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x08, 0x65, 0x61, 0x72, 0x6c, 0x69, 0x65,
+	0x73, 0x74, 0x12, 0x32, 0x0a, 0x06, 0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x06,
+	0x6c, 0x61, 0x74, 0x65, 0x73, 0x74, 0x22, 0x36, 0x0a, 0x0c, 0x54, 0x65, 0x73, 0x74, 0x52, 0x65,
+	0x73, 0x75, 0x6c, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x0e,
+	0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x71,
+	0x0a, 0x07, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x12, 0x2e, 0x0a, 0x03, 0x64, 0x65, 0x66,
+	0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x2e, 0x44, 0x65, 0x66, 0x45,
+	0x6e, 0x74, 0x72, 0x79, 0x52, 0x03, 0x64, 0x65, 0x66, 0x1a, 0x36, 0x0a, 0x08, 0x44, 0x65, 0x66,
+	0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38,
+	0x01, 0x22, 0x34, 0x0a, 0x0a, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x50, 0x61, 0x69, 0x72, 0x12,
+	0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65,
+	0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x4c, 0x0a, 0x14, 0x42, 0x75, 0x67, 0x54, 0x72,
+	0x61, 0x63, 0x6b, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x12,
+	0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x1c, 0x0a, 0x09, 0x63, 0x6f, 0x6d, 0x70, 0x6f,
+	0x6e, 0x65, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6d, 0x70,
+	0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x22, 0x38, 0x0a, 0x0e, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d,
+	0x69, 0x74, 0x52, 0x75, 0x6e, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65,
+	0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12,
+	0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22,
+	0x70, 0x0a, 0x0d, 0x41, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64, 0x42, 0x75, 0x67,
+	0x12, 0x16, 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x20, 0x0a, 0x09, 0x6c, 0x69, 0x6e, 0x6b,
+	0x5f, 0x74, 0x65, 0x78, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03,
+	0x52, 0x08, 0x6c, 0x69, 0x6e, 0x6b, 0x54, 0x65, 0x78, 0x74, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72,
+	0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x03, 0x75, 0x72,
+	0x6c, 0x22, 0x39, 0x0a, 0x09, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1c,
+	0x0a, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x09, 0x61, 0x6c, 0x67, 0x6f, 0x72, 0x69, 0x74, 0x68, 0x6d, 0x12, 0x0e, 0x0a, 0x02,
+	0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x22, 0x58, 0x0a, 0x0c,
+	0x54, 0x65, 0x73, 0x74, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04,
+	0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x12, 0x34, 0x0a, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x18, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x54, 0x65, 0x73, 0x74, 0x4c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x08, 0x6c, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x53, 0x0a, 0x0c, 0x54, 0x65, 0x73, 0x74, 0x4c, 0x6f,
+	0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x65, 0x70, 0x6f, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x65, 0x70, 0x6f, 0x12, 0x1b, 0x0a, 0x09, 0x66, 0x69,
+	0x6c, 0x65, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x66,
+	0x69, 0x6c, 0x65, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x6c, 0x69, 0x6e, 0x65, 0x2a, 0x9a, 0x01, 0x0a, 0x0b,
+	0x42, 0x75, 0x69, 0x6c, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x1c, 0x0a, 0x18, 0x42,
+	0x55, 0x49, 0x4c, 0x44, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50,
+	0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x42, 0x55, 0x49,
+	0x4c, 0x44, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x53,
+	0x53, 0x10, 0x01, 0x12, 0x18, 0x0a, 0x14, 0x42, 0x55, 0x49, 0x4c, 0x44, 0x5f, 0x53, 0x54, 0x41,
+	0x54, 0x55, 0x53, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x02, 0x12, 0x1e, 0x0a,
+	0x1a, 0x42, 0x55, 0x49, 0x4c, 0x44, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x49, 0x4e,
+	0x46, 0x52, 0x41, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x03, 0x12, 0x19, 0x0a,
+	0x15, 0x42, 0x55, 0x49, 0x4c, 0x44, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x41,
+	0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x2a, 0x8f, 0x01, 0x0a, 0x11, 0x45, 0x78, 0x6f,
+	0x6e, 0x65, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x22,
+	0x0a, 0x1e, 0x45, 0x58, 0x4f, 0x4e, 0x45, 0x52, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x52, 0x45,
+	0x41, 0x53, 0x4f, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44,
+	0x10, 0x00, 0x12, 0x16, 0x0a, 0x12, 0x4f, 0x43, 0x43, 0x55, 0x52, 0x53, 0x5f, 0x4f, 0x4e, 0x5f,
+	0x4d, 0x41, 0x49, 0x4e, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x4f, 0x43,
+	0x43, 0x55, 0x52, 0x53, 0x5f, 0x4f, 0x4e, 0x5f, 0x4f, 0x54, 0x48, 0x45, 0x52, 0x5f, 0x43, 0x4c,
+	0x53, 0x10, 0x02, 0x12, 0x10, 0x0a, 0x0c, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x49, 0x54, 0x49,
+	0x43, 0x41, 0x4c, 0x10, 0x03, 0x12, 0x13, 0x0a, 0x0f, 0x55, 0x4e, 0x45, 0x58, 0x50, 0x45, 0x43,
+	0x54, 0x45, 0x44, 0x5f, 0x50, 0x41, 0x53, 0x53, 0x10, 0x04, 0x2a, 0x5d, 0x0a, 0x0f, 0x53, 0x75,
+	0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x12, 0x20, 0x0a,
+	0x1c, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x54, 0x45, 0x44, 0x5f, 0x46, 0x49, 0x4c, 0x54, 0x45,
+	0x52, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12,
+	0x12, 0x0a, 0x0e, 0x4f, 0x4e, 0x4c, 0x59, 0x5f, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x54, 0x45,
+	0x44, 0x10, 0x01, 0x12, 0x14, 0x0a, 0x10, 0x4f, 0x4e, 0x4c, 0x59, 0x5f, 0x55, 0x4e, 0x53, 0x55,
+	0x42, 0x4d, 0x49, 0x54, 0x54, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x64, 0x0a, 0x10, 0x50, 0x72, 0x65,
+	0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x22, 0x0a,
+	0x1e, 0x50, 0x52, 0x45, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x4d,
+	0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10,
+	0x00, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x52, 0x59, 0x5f, 0x52, 0x55, 0x4e, 0x10, 0x01, 0x12, 0x0c,
+	0x0a, 0x08, 0x46, 0x55, 0x4c, 0x4c, 0x5f, 0x52, 0x55, 0x4e, 0x10, 0x02, 0x12, 0x11, 0x0a, 0x0d,
+	0x51, 0x55, 0x49, 0x43, 0x4b, 0x5f, 0x44, 0x52, 0x59, 0x5f, 0x52, 0x55, 0x4e, 0x10, 0x03, 0x2a,
+	0xa2, 0x01, 0x0a, 0x12, 0x50, 0x72, 0x65, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x52, 0x75, 0x6e,
+	0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x24, 0x0a, 0x20, 0x50, 0x52, 0x45, 0x53, 0x55, 0x42,
+	0x4d, 0x49, 0x54, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x55,
+	0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x22, 0x0a, 0x1e,
+	0x50, 0x52, 0x45, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x5f, 0x52, 0x55, 0x4e, 0x5f, 0x53, 0x54,
+	0x41, 0x54, 0x55, 0x53, 0x5f, 0x53, 0x55, 0x43, 0x43, 0x45, 0x45, 0x44, 0x45, 0x44, 0x10, 0x01,
+	0x12, 0x1f, 0x0a, 0x1b, 0x50, 0x52, 0x45, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x5f, 0x52, 0x55,
+	0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10,
+	0x02, 0x12, 0x21, 0x0a, 0x1d, 0x50, 0x52, 0x45, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x5f, 0x52,
+	0x55, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c,
+	0x45, 0x44, 0x10, 0x03, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70,
+	0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_common_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_common_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_common_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_common_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_common_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_common_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_common_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_common_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
+var file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
+var file_infra_appengine_weetbix_proto_v1_common_proto_goTypes = []interface{}{
+	(BuildStatus)(0),              // 0: weetbix.v1.BuildStatus
+	(ExonerationReason)(0),        // 1: weetbix.v1.ExonerationReason
+	(SubmittedFilter)(0),          // 2: weetbix.v1.SubmittedFilter
+	(PresubmitRunMode)(0),         // 3: weetbix.v1.PresubmitRunMode
+	(PresubmitRunStatus)(0),       // 4: weetbix.v1.PresubmitRunStatus
+	(*TimeRange)(nil),             // 5: weetbix.v1.TimeRange
+	(*TestResultId)(nil),          // 6: weetbix.v1.TestResultId
+	(*Variant)(nil),               // 7: weetbix.v1.Variant
+	(*StringPair)(nil),            // 8: weetbix.v1.StringPair
+	(*BugTrackingComponent)(nil),  // 9: weetbix.v1.BugTrackingComponent
+	(*PresubmitRunId)(nil),        // 10: weetbix.v1.PresubmitRunId
+	(*AssociatedBug)(nil),         // 11: weetbix.v1.AssociatedBug
+	(*ClusterId)(nil),             // 12: weetbix.v1.ClusterId
+	(*TestMetadata)(nil),          // 13: weetbix.v1.TestMetadata
+	(*TestLocation)(nil),          // 14: weetbix.v1.TestLocation
+	nil,                           // 15: weetbix.v1.Variant.DefEntry
+	(*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp
+}
+var file_infra_appengine_weetbix_proto_v1_common_proto_depIdxs = []int32{
+	16, // 0: weetbix.v1.TimeRange.earliest:type_name -> google.protobuf.Timestamp
+	16, // 1: weetbix.v1.TimeRange.latest:type_name -> google.protobuf.Timestamp
+	15, // 2: weetbix.v1.Variant.def:type_name -> weetbix.v1.Variant.DefEntry
+	14, // 3: weetbix.v1.TestMetadata.location:type_name -> weetbix.v1.TestLocation
+	4,  // [4:4] is the sub-list for method output_type
+	4,  // [4:4] is the sub-list for method input_type
+	4,  // [4:4] is the sub-list for extension type_name
+	4,  // [4:4] is the sub-list for extension extendee
+	0,  // [0:4] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_common_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_common_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_common_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TimeRange); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestResultId); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Variant); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*StringPair); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*BugTrackingComponent); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*PresubmitRunId); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*AssociatedBug); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ClusterId); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestMetadata); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestLocation); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_common_proto_rawDesc,
+			NumEnums:      5,
+			NumMessages:   11,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_common_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_common_proto_depIdxs,
+		EnumInfos:         file_infra_appengine_weetbix_proto_v1_common_proto_enumTypes,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_common_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_common_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_common_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_common_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_common_proto_depIdxs = nil
+}
diff --git a/analysis/proto/v1/common.proto b/analysis/proto/v1/common.proto
new file mode 100644
index 0000000..df90602
--- /dev/null
+++ b/analysis/proto/v1/common.proto
@@ -0,0 +1,283 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+import "google/api/field_behavior.proto";
+import "google/protobuf/timestamp.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// A range of timestamps.
+message TimeRange {
+  // The oldest timestamp to include in the range.
+  google.protobuf.Timestamp earliest = 1;
+
+  // Include only timestamps that are strictly older than this.
+  google.protobuf.Timestamp latest = 2;
+}
+
+// Identity of a test result.
+message TestResultId {
+  // The test results system.
+  // Currently, the only valid value is "resultdb".
+  string system = 1;
+
+  // ID for the test result in the test results system.
+  // For test results in ResultDB, the format is:
+  // "invocations/{INVOCATION_ID}/tests/{URL_ESCAPED_TEST_ID}/results/{RESULT_ID}"
+  // Where INVOCATION_ID, URL_ESCAPED_TEST_ID and RESULT_ID are values defined
+  // in ResultDB.
+  string id = 2;
+}
+
+// Variant represents a way of running a test case.
+//
+// The same test case can be executed in different ways, for example on
+// different OS, GPUs, with different compile options or runtime flags.
+message Variant {
+  // The definition of the variant. Each key-value pair represents a
+  // parameter describing how the test was run (e.g. OS, GPU, etc.).
+  map<string, string> def = 1;
+}
+
+message StringPair {
+  // Regex: ^[a-z][a-z0-9_]*(/[a-z][a-z0-9_]*)*$
+  // Max length: 64.
+  string key = 1;
+
+  // Max length: 256.
+  string value = 2;
+}
+
+// Identity of a bug tracking component in a bug tracking system.
+message BugTrackingComponent {
+  // The bug tracking system corresponding to this test case, as identified
+  // by the test results system.
+  // Currently, the only valid value is "monorail".
+  string system = 1;
+
+  // The bug tracking component corresponding to this test case, as identified
+  // by the test results system.
+  // If the bug tracking system is monorail, this is the component as the
+  // user would see it, e.g. "Infra>Test>Flakiness". For monorail, the bug
+  // tracking project (e.g. "chromium") is not encoded, but assumed to be
+  // specified in the project's Weetbix configuration.
+  string component = 2;
+}
+
+// Identity of a presubmit run (also known as a "CQ Run" or "CV Run").
+message PresubmitRunId {
+  // The system that was used to process the presubmit run.
+  // Currently, the only valid value is "luci-cv" for LUCI Commit Verifier
+  // (LUCI CV).
+  string system = 1;
+
+  // Identity of the presubmit run.
+  // If the presubmit system is LUCI CV, the format of this value is:
+  //   "{LUCI_PROJECT}/{LUCI_CV_ID}", e.g.
+  //   "infra/8988819463854-1-f94732fe20056fd1".
+  string id = 2;
+}
+
+// Identity of a bug in a bug-tracking system.
+message AssociatedBug {
+  // System is the bug tracking system of the bug. This is either
+  // "monorail" or "buganizer".
+  string system = 1;
+
+  // Id is the bug tracking system-specific identity of the bug.
+  // For monorail, the scheme is {project}/{numeric_id}, for
+  // buganizer the scheme is {numeric_id}.
+  string id = 2;
+
+  // A human-readable name for the bug. This is typically the
+  // bug shortlink (e.g. "crbug.com/1234567").
+  string link_text = 3
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The resolved bug URL, e.g.
+  // E.g. "https://bugs.chromium.org/p/chromium/issues/detail?id=123456".
+  string url = 4
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+}
+
+// ClusterId represents the identity of a cluster. The LUCI Project is
+// omitted as it is assumed to be implicit from the context.
+//
+// This is often used in place of the resource name of the cluster
+// (in the sense of https://google.aip.dev/122) as clients may need
+// to access individual parts of the resource name (e.g. to determine
+// the algorithm used) and it is not desirable to make clients parse
+// the resource name.
+message ClusterId {
+  // Algorithm is the name of the clustering algorithm that identified
+  // the cluster.
+  string algorithm = 1;
+
+  // Id is the cluster identifier returned by the algorithm. The underlying
+  // identifier is at most 16 bytes, but is represented here as a hexadecimal
+  // string of up to 32 lowercase hexadecimal characters.
+  string id = 2;
+}
+
+// BuildStatus the result of the build in which the test verdict was produced.
+// This can be used to detect if the test verdict is incomplete (e.g. because
+// an infra failure or cancellation occurred), and whether the unexpected
+// test verdict was also followed by a failing build.
+//
+// Note: All values prefixed with BUILD_STATUS_ as the names are generic
+// and likely to conflict with other/future enumerations otherwise.
+// See https://google.aip.dev/126.
+enum BuildStatus {
+  // A build must not have this status.
+  BUILD_STATUS_UNSPECIFIED = 0;
+
+  // The build succeeded.
+  BUILD_STATUS_SUCCESS = 1;
+
+  // The build failed.
+  BUILD_STATUS_FAILURE = 2;
+
+  // The build encountered an infrastructure failure.
+  BUILD_STATUS_INFRA_FAILURE = 3;
+
+  // The build was canceled.
+  BUILD_STATUS_CANCELED = 4;
+}
+
+// ExonerationReason captures a reason why a test failure was
+// exonerated. Exonerated means the failure was ignored and did not
+// have further impact, in terms of causing the build to fail or
+// rejecting the CL being tested in a presubmit run.
+//
+// Based on https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/resultdb/proto/v1/test_result.proto?q=ExonerationReason&type=cs.
+enum ExonerationReason {
+  // A test failure must not have this status.
+  EXONERATION_REASON_UNSPECIFIED = 0;
+
+  // Similar unexpected results were observed on a mainline branch
+  // (i.e. against a build without unsubmitted changes applied).
+  // (For avoidance of doubt, this includes both flakily and
+  // deterministically occurring unexpected results.)
+  // Applies to unexpected results in presubmit/CQ runs only.
+  OCCURS_ON_MAINLINE = 1;
+
+  // Similar unexpected results were observed in presubmit run(s) for other,
+  // unrelated CL(s). (This is suggestive of the issue being present
+  // on mainline but is not confirmed as there are possible confounding
+  // factors, like how tests are run on CLs vs how tests are run on
+  // mainline branches.)
+  // Applies to unexpected results in presubmit/CQ runs only.
+  OCCURS_ON_OTHER_CLS = 2;
+
+  // The tests are not critical to the test subject (e.g. CL) passing.
+  // This could be because more data is being collected to determine if
+  // the tests are stable enough to be made critical (as is often the
+  // case for experimental test suites).
+  NOT_CRITICAL = 3;
+
+  // The test result was an unexpected pass. (Note that such an exoneration is
+  // not automatically created for unexpected passes, unless the option is
+  // specified to ResultSink or the project manually creates one).
+  UNEXPECTED_PASS = 4;
+}
+
+// Information about a test.
+//
+// As of Oct 2021, it's an exact copy of luci.resultdb.v1.TestMetadata, but
+// we'd like to keep a local definition of the proto to keep the possibility that
+// we need to diverge down the track.
+message TestMetadata {
+  // The original test name.
+  string name = 1;
+
+  // Where the test is defined, e.g. the file name.
+  // location.repo MUST be specified.
+  TestLocation location = 2;
+}
+
+// Location of the test definition.
+message TestLocation {
+  // Gitiles URL as the identifier for a repo.
+  // Format for Gitiles URL: https://<host>/<project>
+  // For example "https://chromium.googlesource.com/chromium/src"
+  // Must not end with ".git".
+  // SHOULD be specified.
+  string repo = 1;
+
+  // Name of the file where the test is defined.
+  // For files in a repository, must start with "//"
+  // Example: "//components/payments/core/payment_request_data_util_unittest.cc"
+  // Max length: 512.
+  // MUST not use backslashes.
+  // Required.
+  string file_name = 2;
+
+  // One-based line number where the test is defined.
+  int32 line = 3;
+}
+
+// SubmittedFilter filters test verdicts based on whether they had unsubmitted
+// changes.
+enum SubmittedFilter {
+  // Default value. Include all test verdicts.
+  SUBMITTED_FILTER_UNSPECIFIED = 0;
+
+  // Only include test verdicts that don't have unsubmitted changes.
+  ONLY_SUBMITTED = 1;
+
+  // Only include test verdicts that have unsubmitted changes.
+  ONLY_UNSUBMITTED = 2;
+}
+
+// PresubmitRunMode describes the mode of a presubmit run. Currently
+// based on LUCI CV run mode enumeration at
+// https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/cv/api/bigquery/v1/attempt.proto?q=QUICK_DRY_RUN&type=cs.
+enum PresubmitRunMode {
+  // A presubmit run must not have this status.
+  PRESUBMIT_RUN_MODE_UNSPECIFIED = 0;
+
+  // Run all tests but do not submit.
+  DRY_RUN = 1;
+
+  // Run all tests and potentially submit.
+  FULL_RUN = 2;
+
+  // Run some tests but do not submit.
+  QUICK_DRY_RUN = 3;
+}
+
+// PresubmitRunStatus is the ending status of a presubmit run.
+//
+// Note: All values prefixed with PRESUBMIT_RUN_STATUS_ as the names are
+// generic and likely to conflict with other/future enumerations otherwise.
+// See https://google.aip.dev/126.
+//
+// Based on https://source.chromium.org/chromium/infra/infra/+/main:go/src/go.chromium.org/luci/cv/internal/run/storage.proto;l=28?q=LUCI%20CV%20status%20lang:proto.
+enum PresubmitRunStatus {
+  // A build must not have this status.
+  PRESUBMIT_RUN_STATUS_UNSPECIFIED = 0;
+
+  // The run succeeded.
+  PRESUBMIT_RUN_STATUS_SUCCEEDED = 1;
+
+  // The run failed.
+  PRESUBMIT_RUN_STATUS_FAILED = 2;
+
+  // The run was canceled.
+  PRESUBMIT_RUN_STATUS_CANCELED = 3;
+}
diff --git a/analysis/proto/v1/failure_reason.pb.go b/analysis/proto/v1/failure_reason.pb.go
new file mode 100644
index 0000000..1f0d658
--- /dev/null
+++ b/analysis/proto/v1/failure_reason.pb.go
@@ -0,0 +1,174 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/failure_reason.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Information about why a test failed.
+type FailureReason struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The error message that ultimately caused the test to fail. This should
+	// only be the error message and should not include any stack traces.
+	// An example would be the message from an Exception in a Java test.
+	// In the case that a test failed due to multiple expectation failures, any
+	// immediately fatal failure should be chosen, or otherwise the first
+	// expectation failure.
+	// If this field is empty, other fields may be used to cluster the failure
+	// instead.
+	//
+	// The size of the message must be equal to or smaller than 1024 bytes in
+	// UTF-8.
+	PrimaryErrorMessage string `protobuf:"bytes,1,opt,name=primary_error_message,json=primaryErrorMessage,proto3" json:"primary_error_message,omitempty"`
+}
+
+func (x *FailureReason) Reset() {
+	*x = FailureReason{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_failure_reason_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *FailureReason) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FailureReason) ProtoMessage() {}
+
+func (x *FailureReason) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_failure_reason_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use FailureReason.ProtoReflect.Descriptor instead.
+func (*FailureReason) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *FailureReason) GetPrimaryErrorMessage() string {
+	if x != nil {
+		return x.PrimaryErrorMessage
+	}
+	return ""
+}
+
+var File_infra_appengine_weetbix_proto_v1_failure_reason_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDesc = []byte{
+	0x0a, 0x35, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f,
+	0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x22, 0x43, 0x0a, 0x0d, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x65,
+	0x61, 0x73, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x15, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x5f,
+	0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x13, 0x70, 0x72, 0x69, 0x6d, 0x61, 0x72, 0x79, 0x45, 0x72, 0x72, 0x6f,
+	0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72,
+	0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_failure_reason_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_proto_v1_failure_reason_proto_goTypes = []interface{}{
+	(*FailureReason)(nil), // 0: weetbix.v1.FailureReason
+}
+var file_infra_appengine_weetbix_proto_v1_failure_reason_proto_depIdxs = []int32{
+	0, // [0:0] is the sub-list for method output_type
+	0, // [0:0] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_failure_reason_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_failure_reason_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_failure_reason_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_failure_reason_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*FailureReason); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_failure_reason_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_failure_reason_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_failure_reason_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_failure_reason_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_failure_reason_proto_depIdxs = nil
+}
diff --git a/analysis/proto/v1/failure_reason.proto b/analysis/proto/v1/failure_reason.proto
new file mode 100644
index 0000000..b4f7723
--- /dev/null
+++ b/analysis/proto/v1/failure_reason.proto
@@ -0,0 +1,35 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// Information about why a test failed.
+message FailureReason {
+  // The error message that ultimately caused the test to fail. This should
+  // only be the error message and should not include any stack traces.
+  // An example would be the message from an Exception in a Java test.
+  // In the case that a test failed due to multiple expectation failures, any
+  // immediately fatal failure should be chosen, or otherwise the first
+  // expectation failure.
+  // If this field is empty, other fields may be used to cluster the failure
+  // instead.
+  //
+  // The size of the message must be equal to or smaller than 1024 bytes in
+  // UTF-8.
+  string primary_error_message = 1;
+}
diff --git a/analysis/proto/v1/gen.go b/analysis/proto/v1/gen.go
new file mode 100644
index 0000000..222f547
--- /dev/null
+++ b/analysis/proto/v1/gen.go
@@ -0,0 +1,23 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package weetbixpb
+
+//go:generate cproto
+//go:generate svcdec -type RulesServer
+//go:generate svcdec -type ProjectsServer
+//go:generate svcdec -type InitDataGeneratorServer
+//go:generate svcdec -type ClustersServer
+//go:generate svcdec -type TestHistoryServer
+//go:generate svcdec -type TestVariantsServer
diff --git a/analysis/proto/v1/init_data.pb.go b/analysis/proto/v1/init_data.pb.go
new file mode 100644
index 0000000..278d4cb
--- /dev/null
+++ b/analysis/proto/v1/init_data.pb.go
@@ -0,0 +1,617 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/init_data.proto
+
+package weetbixpb
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// A request to get the server's initialization data.
+type GenerateInitDataRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The URL of the page requesting the data, this is used to generate the logout URL.
+	ReferrerUrl string `protobuf:"bytes,1,opt,name=referrer_url,json=referrerUrl,proto3" json:"referrer_url,omitempty"`
+}
+
+func (x *GenerateInitDataRequest) Reset() {
+	*x = GenerateInitDataRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GenerateInitDataRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GenerateInitDataRequest) ProtoMessage() {}
+
+func (x *GenerateInitDataRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GenerateInitDataRequest.ProtoReflect.Descriptor instead.
+func (*GenerateInitDataRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *GenerateInitDataRequest) GetReferrerUrl() string {
+	if x != nil {
+		return x.ReferrerUrl
+	}
+	return ""
+}
+
+// A response object containing the initialization data.
+type GenerateInitDataResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	InitData *InitData `protobuf:"bytes,1,opt,name=init_data,json=initData,proto3" json:"init_data,omitempty"`
+}
+
+func (x *GenerateInitDataResponse) Reset() {
+	*x = GenerateInitDataResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GenerateInitDataResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GenerateInitDataResponse) ProtoMessage() {}
+
+func (x *GenerateInitDataResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GenerateInitDataResponse.ProtoReflect.Descriptor instead.
+func (*GenerateInitDataResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GenerateInitDataResponse) GetInitData() *InitData {
+	if x != nil {
+		return x.InitData
+	}
+	return nil
+}
+
+// The data describing the current state of the weetbix server and the user logged in.
+// Data provided here can be used to initialise the client UI.
+type InitData struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Hostnames *Hostnames `protobuf:"bytes,1,opt,name=hostnames,proto3" json:"hostnames,omitempty"`
+	User      *User      `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"`
+	AuthUrls  *AuthUrls  `protobuf:"bytes,3,opt,name=auth_urls,json=authUrls,proto3" json:"auth_urls,omitempty"`
+}
+
+func (x *InitData) Reset() {
+	*x = InitData{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *InitData) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*InitData) ProtoMessage() {}
+
+func (x *InitData) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use InitData.ProtoReflect.Descriptor instead.
+func (*InitData) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *InitData) GetHostnames() *Hostnames {
+	if x != nil {
+		return x.Hostnames
+	}
+	return nil
+}
+
+func (x *InitData) GetUser() *User {
+	if x != nil {
+		return x.User
+	}
+	return nil
+}
+
+func (x *InitData) GetAuthUrls() *AuthUrls {
+	if x != nil {
+		return x.AuthUrls
+	}
+	return nil
+}
+
+// The external services hostnames.
+type Hostnames struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The monorail hostname, e.g. "monorail-dev.appspot.com".
+	MonorailHostname string `protobuf:"bytes,1,opt,name=monorail_hostname,json=monorailHostname,proto3" json:"monorail_hostname,omitempty"`
+}
+
+func (x *Hostnames) Reset() {
+	*x = Hostnames{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Hostnames) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Hostnames) ProtoMessage() {}
+
+func (x *Hostnames) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Hostnames.ProtoReflect.Descriptor instead.
+func (*Hostnames) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *Hostnames) GetMonorailHostname() string {
+	if x != nil {
+		return x.MonorailHostname
+	}
+	return ""
+}
+
+// The logged in user's data.
+type User struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The user's email address.
+	Email string `protobuf:"bytes,1,opt,name=email,proto3" json:"email,omitempty"`
+}
+
+func (x *User) Reset() {
+	*x = User{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *User) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*User) ProtoMessage() {}
+
+func (x *User) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use User.ProtoReflect.Descriptor instead.
+func (*User) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *User) GetEmail() string {
+	if x != nil {
+		return x.Email
+	}
+	return ""
+}
+
+// The authentication URLs that can be used to change the user's auth status.
+type AuthUrls struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The logout URL.
+	LogoutUrl string `protobuf:"bytes,1,opt,name=logout_url,json=logoutUrl,proto3" json:"logout_url,omitempty"`
+}
+
+func (x *AuthUrls) Reset() {
+	*x = AuthUrls{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *AuthUrls) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AuthUrls) ProtoMessage() {}
+
+func (x *AuthUrls) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use AuthUrls.ProtoReflect.Descriptor instead.
+func (*AuthUrls) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *AuthUrls) GetLogoutUrl() string {
+	if x != nil {
+		return x.LogoutUrl
+	}
+	return ""
+}
+
+var File_infra_appengine_weetbix_proto_v1_init_data_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDesc = []byte{
+	0x0a, 0x30, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x69, 0x6e, 0x69, 0x74, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x22, 0x3c,
+	0x0a, 0x17, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x69, 0x74, 0x44, 0x61,
+	0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x66,
+	0x65, 0x72, 0x72, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0b, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x22, 0x4d, 0x0a, 0x18,
+	0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x69, 0x74, 0x44, 0x61, 0x74, 0x61,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x31, 0x0a, 0x09, 0x69, 0x6e, 0x69, 0x74,
+	0x5f, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x49, 0x6e, 0x69, 0x74, 0x44, 0x61, 0x74,
+	0x61, 0x52, 0x08, 0x69, 0x6e, 0x69, 0x74, 0x44, 0x61, 0x74, 0x61, 0x22, 0x98, 0x01, 0x0a, 0x08,
+	0x49, 0x6e, 0x69, 0x74, 0x44, 0x61, 0x74, 0x61, 0x12, 0x33, 0x0a, 0x09, 0x68, 0x6f, 0x73, 0x74,
+	0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d,
+	0x65, 0x73, 0x52, 0x09, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x24, 0x0a,
+	0x04, 0x75, 0x73, 0x65, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x73, 0x65, 0x72, 0x52, 0x04, 0x75,
+	0x73, 0x65, 0x72, 0x12, 0x31, 0x0a, 0x09, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x75, 0x72, 0x6c, 0x73,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x55, 0x72, 0x6c, 0x73, 0x52, 0x08, 0x61, 0x75,
+	0x74, 0x68, 0x55, 0x72, 0x6c, 0x73, 0x22, 0x38, 0x0a, 0x09, 0x48, 0x6f, 0x73, 0x74, 0x6e, 0x61,
+	0x6d, 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x5f,
+	0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10,
+	0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x48, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65,
+	0x22, 0x1c, 0x0a, 0x04, 0x55, 0x73, 0x65, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x6d, 0x61, 0x69,
+	0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x22, 0x29,
+	0x0a, 0x08, 0x41, 0x75, 0x74, 0x68, 0x55, 0x72, 0x6c, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x6c, 0x6f,
+	0x67, 0x6f, 0x75, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09,
+	0x6c, 0x6f, 0x67, 0x6f, 0x75, 0x74, 0x55, 0x72, 0x6c, 0x32, 0x74, 0x0a, 0x11, 0x49, 0x6e, 0x69,
+	0x74, 0x44, 0x61, 0x74, 0x61, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x12, 0x5f,
+	0x0a, 0x10, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x69, 0x74, 0x44, 0x61,
+	0x74, 0x61, 0x12, 0x23, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x69, 0x74, 0x44, 0x61, 0x74, 0x61,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x49, 0x6e, 0x69,
+	0x74, 0x44, 0x61, 0x74, 0x61, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42,
+	0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69,
+	0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes = make([]protoimpl.MessageInfo, 6)
+var file_infra_appengine_weetbix_proto_v1_init_data_proto_goTypes = []interface{}{
+	(*GenerateInitDataRequest)(nil),  // 0: weetbix.v1.GenerateInitDataRequest
+	(*GenerateInitDataResponse)(nil), // 1: weetbix.v1.GenerateInitDataResponse
+	(*InitData)(nil),                 // 2: weetbix.v1.InitData
+	(*Hostnames)(nil),                // 3: weetbix.v1.Hostnames
+	(*User)(nil),                     // 4: weetbix.v1.User
+	(*AuthUrls)(nil),                 // 5: weetbix.v1.AuthUrls
+}
+var file_infra_appengine_weetbix_proto_v1_init_data_proto_depIdxs = []int32{
+	2, // 0: weetbix.v1.GenerateInitDataResponse.init_data:type_name -> weetbix.v1.InitData
+	3, // 1: weetbix.v1.InitData.hostnames:type_name -> weetbix.v1.Hostnames
+	4, // 2: weetbix.v1.InitData.user:type_name -> weetbix.v1.User
+	5, // 3: weetbix.v1.InitData.auth_urls:type_name -> weetbix.v1.AuthUrls
+	0, // 4: weetbix.v1.InitDataGenerator.GenerateInitData:input_type -> weetbix.v1.GenerateInitDataRequest
+	1, // 5: weetbix.v1.InitDataGenerator.GenerateInitData:output_type -> weetbix.v1.GenerateInitDataResponse
+	5, // [5:6] is the sub-list for method output_type
+	4, // [4:5] is the sub-list for method input_type
+	4, // [4:4] is the sub-list for extension type_name
+	4, // [4:4] is the sub-list for extension extendee
+	0, // [0:4] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_init_data_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_init_data_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_init_data_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GenerateInitDataRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GenerateInitDataResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*InitData); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Hostnames); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*User); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*AuthUrls); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   6,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_init_data_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_init_data_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_init_data_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_init_data_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_init_data_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_init_data_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_init_data_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// InitDataGeneratorClient is the client API for InitDataGenerator service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type InitDataGeneratorClient interface {
+	// Generates and returns init data.
+	// Designed to conform to https://google.aip.dev/136.
+	GenerateInitData(ctx context.Context, in *GenerateInitDataRequest, opts ...grpc.CallOption) (*GenerateInitDataResponse, error)
+}
+type initDataGeneratorPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewInitDataGeneratorPRPCClient(client *prpc.Client) InitDataGeneratorClient {
+	return &initDataGeneratorPRPCClient{client}
+}
+
+func (c *initDataGeneratorPRPCClient) GenerateInitData(ctx context.Context, in *GenerateInitDataRequest, opts ...grpc.CallOption) (*GenerateInitDataResponse, error) {
+	out := new(GenerateInitDataResponse)
+	err := c.client.Call(ctx, "weetbix.v1.InitDataGenerator", "GenerateInitData", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type initDataGeneratorClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewInitDataGeneratorClient(cc grpc.ClientConnInterface) InitDataGeneratorClient {
+	return &initDataGeneratorClient{cc}
+}
+
+func (c *initDataGeneratorClient) GenerateInitData(ctx context.Context, in *GenerateInitDataRequest, opts ...grpc.CallOption) (*GenerateInitDataResponse, error) {
+	out := new(GenerateInitDataResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.InitDataGenerator/GenerateInitData", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// InitDataGeneratorServer is the server API for InitDataGenerator service.
+type InitDataGeneratorServer interface {
+	// Generates and returns init data.
+	// Designed to conform to https://google.aip.dev/136.
+	GenerateInitData(context.Context, *GenerateInitDataRequest) (*GenerateInitDataResponse, error)
+}
+
+// UnimplementedInitDataGeneratorServer can be embedded to have forward compatible implementations.
+type UnimplementedInitDataGeneratorServer struct {
+}
+
+func (*UnimplementedInitDataGeneratorServer) GenerateInitData(context.Context, *GenerateInitDataRequest) (*GenerateInitDataResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GenerateInitData not implemented")
+}
+
+func RegisterInitDataGeneratorServer(s prpc.Registrar, srv InitDataGeneratorServer) {
+	s.RegisterService(&_InitDataGenerator_serviceDesc, srv)
+}
+
+func _InitDataGenerator_GenerateInitData_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GenerateInitDataRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(InitDataGeneratorServer).GenerateInitData(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.InitDataGenerator/GenerateInitData",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(InitDataGeneratorServer).GenerateInitData(ctx, req.(*GenerateInitDataRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _InitDataGenerator_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.v1.InitDataGenerator",
+	HandlerType: (*InitDataGeneratorServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GenerateInitData",
+			Handler:    _InitDataGenerator_GenerateInitData_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/proto/v1/init_data.proto",
+}
diff --git a/analysis/proto/v1/init_data.proto b/analysis/proto/v1/init_data.proto
new file mode 100644
index 0000000..052a976
--- /dev/null
+++ b/analysis/proto/v1/init_data.proto
@@ -0,0 +1,53 @@
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// A service that provides access to initialization data required by clients.
+service InitDataGenerator {
+  // Generates and returns init data.
+  // Designed to conform to https://google.aip.dev/136.
+  rpc GenerateInitData(GenerateInitDataRequest) returns (GenerateInitDataResponse) {};
+}
+
+// A request to get the server's initialization data.
+message GenerateInitDataRequest {
+
+    // The URL of the page requesting the data, this is used to generate the logout URL.
+    string referrer_url = 1;
+}
+
+// A response object containing the initialization data.
+message GenerateInitDataResponse {
+    InitData init_data = 1;
+}
+
+// The data describing the current state of the weetbix server and the user logged in.
+// Data provided here can be used to initialise the client UI.
+message InitData {
+    Hostnames hostnames = 1;
+    User user = 2;
+    AuthUrls auth_urls = 3;
+}
+
+// The external services hostnames.
+message Hostnames {
+
+    // The monorail hostname, e.g. "monorail-dev.appspot.com".
+    string monorail_hostname = 1;
+}
+
+// The logged in user's data.
+message User {
+
+    // The user's email address.
+    string email = 1;
+}
+
+// The authentication URLs that can be used to change the user's auth status.
+message AuthUrls {
+
+    // The logout URL.
+    string logout_url = 1;
+}
\ No newline at end of file
diff --git a/analysis/proto/v1/initdatageneratorserver_dec.go b/analysis/proto/v1/initdatageneratorserver_dec.go
new file mode 100644
index 0000000..3c6539a
--- /dev/null
+++ b/analysis/proto/v1/initdatageneratorserver_dec.go
@@ -0,0 +1,41 @@
+// Code generated by svcdec; DO NOT EDIT.
+
+package weetbixpb
+
+import (
+	"context"
+
+	proto "github.com/golang/protobuf/proto"
+)
+
+type DecoratedInitDataGenerator struct {
+	// Service is the service to decorate.
+	Service InitDataGeneratorServer
+	// Prelude is called for each method before forwarding the call to Service.
+	// If Prelude returns an error, then the call is skipped and the error is
+	// processed via the Postlude (if one is defined), or it is returned directly.
+	Prelude func(ctx context.Context, methodName string, req proto.Message) (context.Context, error)
+	// Postlude is called for each method after Service has processed the call, or
+	// after the Prelude has returned an error. This takes the the Service's
+	// response proto (which may be nil) and/or any error. The decorated
+	// service will return the response (possibly mutated) and error that Postlude
+	// returns.
+	Postlude func(ctx context.Context, methodName string, rsp proto.Message, err error) error
+}
+
+func (s *DecoratedInitDataGenerator) GenerateInitData(ctx context.Context, req *GenerateInitDataRequest) (rsp *GenerateInitDataResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "GenerateInitData", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.GenerateInitData(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "GenerateInitData", rsp, err)
+	}
+	return
+}
diff --git a/analysis/proto/v1/pb.discovery.go b/analysis/proto/v1/pb.discovery.go
new file mode 100644
index 0000000..1b4ad9b
--- /dev/null
+++ b/analysis/proto/v1/pb.discovery.go
@@ -0,0 +1,3939 @@
+// Code generated by cproto. DO NOT EDIT.
+
+package weetbixpb
+
+import "go.chromium.org/luci/grpc/discovery"
+
+import "google.golang.org/protobuf/types/descriptorpb"
+
+func init() {
+	discovery.RegisterDescriptorSetCompressed(
+		[]string{
+			"weetbix.v1.Clusters", "weetbix.v1.InitDataGenerator", "weetbix.v1.Projects", "weetbix.v1.Rules", "weetbix.v1.TestHistory", "weetbix.v1.TestVariants",
+		},
+		[]byte{31, 139,
+			8, 0, 0, 0, 0, 0, 0, 255, 236, 189, 125, 124, 28, 199,
+			117, 32, 136, 238, 174, 30, 12, 10, 31, 28, 52, 32, 9, 28,
+			146, 98, 113, 72, 10, 0, 57, 24, 144, 32, 37, 89, 164, 101,
+			45, 8, 64, 20, 100, 144, 224, 2, 160, 101, 201, 63, 11, 106,
+			204, 212, 96, 90, 236, 233, 30, 119, 247, 0, 28, 217, 218, 232,
+			226, 117, 116, 113, 236, 156, 157, 216, 142, 179, 254, 60, 39, 222,
+			93, 57, 190, 213, 198, 201, 229, 99, 111, 125, 222, 36, 155, 205,
+			38, 190, 56, 187, 185, 108, 46, 187, 222, 95, 146, 115, 98, 199,
+			187, 190, 245, 57, 118, 62, 28, 199, 73, 238, 247, 94, 125, 116,
+			207, 0, 148, 100, 199, 202, 254, 113, 230, 79, 250, 97, 94, 117,
+			213, 171, 170, 87, 175, 94, 189, 122, 245, 234, 21, 125, 87, 142,
+			158, 245, 130, 122, 228, 206, 186, 173, 22, 15, 182, 189, 128, 207,
+			238, 114, 158, 108, 121, 55, 103, 91, 81, 152, 132, 179, 59, 103,
+			103, 171, 13, 55, 216, 230, 190, 23, 39, 21, 76, 115, 168, 204,
+			82, 217, 57, 91, 218, 160, 116, 65, 127, 119, 28, 74, 26, 97,
+			156, 76, 24, 204, 152, 26, 88, 195, 223, 206, 237, 52, 39, 48,
+			76, 152, 204, 152, 178, 214, 36, 228, 20, 105, 190, 229, 38, 213,
+			70, 204, 147, 9, 139, 25, 83, 246, 154, 134, 47, 149, 31, 59,
+			245, 98, 205, 186, 40, 19, 90, 91, 15, 255, 27, 66, 115, 14,
+			33, 125, 227, 6, 253, 152, 65, 141, 33, 199, 34, 125, 206, 220,
+			135, 12, 182, 16, 182, 58, 145, 183, 221, 72, 216, 220, 153, 185,
+			57, 182, 209, 224, 108, 161, 17, 133, 77, 175, 221, 100, 243, 237,
+			164, 17, 70, 113, 133, 205, 251, 62, 195, 76, 49, 139, 120, 204,
+			163, 29, 94, 171, 80, 118, 61, 230, 44, 172, 179, 164, 225, 197,
+			44, 14, 219, 81, 149, 179, 106, 88, 227, 204, 139, 217, 118, 184,
+			195, 163, 128, 215, 216, 86, 135, 185, 236, 210, 250, 226, 76, 156,
+			116, 124, 206, 124, 175, 202, 131, 152, 179, 164, 225, 38, 172, 234,
+			6, 108, 139, 83, 86, 15, 219, 65, 141, 121, 1, 75, 26, 156,
+			173, 44, 47, 44, 93, 93, 95, 98, 117, 207, 231, 21, 74, 243,
+			212, 48, 29, 43, 215, 55, 6, 191, 242, 142, 149, 239, 155, 167,
+			3, 212, 204, 15, 138, 159, 151, 169, 73, 250, 28, 50, 216, 55,
+			110, 20, 47, 178, 121, 150, 25, 7, 182, 208, 142, 34, 30, 36,
+			126, 135, 69, 188, 5, 205, 14, 146, 152, 133, 129, 223, 97, 151,
+			121, 20, 121, 9, 187, 38, 73, 25, 87, 40, 165, 212, 34, 125,
+			134, 99, 13, 230, 29, 250, 16, 37, 164, 207, 236, 115, 172, 97,
+			211, 41, 94, 84, 185, 97, 160, 2, 183, 201, 203, 140, 87, 182,
+			43, 172, 84, 149, 84, 154, 137, 248, 142, 199, 119, 43, 219, 97,
+			184, 237, 115, 65, 135, 74, 53, 108, 150, 42, 148, 14, 81, 27,
+			48, 217, 128, 42, 175, 32, 195, 177, 134, 7, 134, 21, 100, 57,
+			214, 112, 97, 148, 78, 99, 157, 134, 99, 21, 204, 177, 226, 97,
+			38, 248, 133, 5, 237, 230, 22, 143, 100, 141, 103, 231, 206, 157,
+			191, 91, 35, 53, 108, 200, 219, 175, 32, 40, 153, 31, 81, 144,
+			229, 88, 133, 81, 135, 78, 33, 82, 211, 177, 198, 204, 219, 138,
+			135, 68, 127, 89, 204, 147, 30, 188, 26, 167, 105, 67, 86, 133,
+			19, 90, 51, 150, 47, 40, 200, 114, 172, 177, 177, 241, 173, 28,
+			50, 215, 57, 250, 43, 103, 233, 81, 209, 101, 193, 110, 91, 237,
+			250, 108, 226, 53, 121, 156, 184, 205, 150, 156, 4, 7, 68, 134,
+			138, 202, 80, 186, 72, 7, 54, 84, 30, 103, 130, 246, 199, 188,
+			26, 6, 181, 24, 231, 130, 181, 166, 64, 103, 156, 218, 129, 27,
+			132, 49, 206, 6, 123, 77, 0, 151, 222, 98, 208, 177, 106, 216,
+			172, 244, 32, 189, 52, 162, 81, 94, 131, 164, 107, 198, 99, 115,
+			50, 203, 118, 232, 187, 193, 118, 37, 140, 182, 51, 109, 236, 180,
+			120, 60, 123, 35, 8, 119, 131, 180, 189, 173, 173, 175, 27, 198,
+			71, 76, 235, 242, 181, 75, 31, 51, 239, 188, 44, 74, 95, 147,
+			69, 42, 143, 112, 223, 127, 53, 20, 216, 128, 178, 15, 255, 242,
+			44, 237, 119, 236, 59, 251, 126, 200, 48, 232, 255, 49, 132, 115,
+			233, 206, 62, 103, 238, 23, 134, 24, 150, 168, 134, 62, 187, 212,
+			174, 215, 121, 20, 179, 25, 38, 112, 77, 198, 172, 230, 38, 46,
+			243, 130, 132, 71, 130, 71, 89, 61, 140, 154, 110, 66, 187, 38,
+			224, 153, 87, 200, 2, 108, 57, 168, 86, 216, 45, 230, 93, 35,
+			73, 90, 241, 133, 217, 217, 26, 223, 225, 126, 216, 226, 81, 172,
+			104, 82, 13, 155, 162, 167, 213, 208, 159, 217, 18, 141, 152, 165,
+			148, 173, 241, 154, 23, 39, 145, 183, 213, 78, 188, 48, 96, 110,
+			80, 99, 237, 152, 195, 148, 147, 243, 22, 82, 182, 188, 192, 141,
+			58, 216, 174, 184, 204, 118, 189, 164, 193, 194, 8, 255, 134, 237,
+			132, 178, 102, 88, 243, 234, 94, 213, 5, 12, 101, 230, 70, 156,
+			181, 120, 212, 244, 146, 132, 215, 88, 43, 10, 119, 188, 26, 175,
+			137, 121, 13, 211, 184, 30, 250, 126, 184, 235, 5, 219, 12, 134,
+			212, 131, 66, 49, 20, 162, 172, 201, 147, 11, 148, 50, 248, 119,
+			170, 167, 97, 49, 136, 147, 172, 36, 105, 182, 227, 132, 69, 60,
+			113, 165, 112, 112, 183, 194, 29, 248, 36, 41, 70, 89, 16, 38,
+			94, 149, 151, 133, 12, 130, 105, 15, 24, 178, 53, 6, 181, 158,
+			230, 212, 188, 184, 234, 187, 94, 147, 71, 149, 91, 53, 194, 11,
+			178, 180, 80, 141, 104, 69, 97, 173, 93, 229, 105, 59, 104, 218,
+			144, 191, 85, 59, 168, 18, 125, 181, 176, 218, 110, 242, 32, 113,
+			213, 32, 205, 134, 17, 11, 147, 6, 143, 88, 211, 77, 120, 228,
+			185, 126, 156, 146, 26, 7, 40, 105, 112, 202, 178, 173, 215, 157,
+			186, 202, 61, 44, 9, 136, 65, 108, 65, 131, 178, 188, 21, 132,
+			233, 55, 164, 187, 151, 196, 208, 163, 64, 160, 10, 163, 152, 53,
+			221, 14, 219, 226, 192, 41, 53, 150, 132, 140, 7, 181, 48, 2,
+			145, 31, 65, 35, 154, 97, 194, 153, 160, 73, 18, 179, 26, 143,
+			188, 29, 94, 99, 245, 40, 108, 82, 181, 34, 212, 147, 93, 96,
+			19, 201, 65, 44, 110, 241, 42, 112, 16, 107, 69, 30, 48, 86,
+			4, 188, 19, 8, 46, 138, 99, 108, 59, 101, 27, 15, 45, 175,
+			179, 245, 213, 7, 55, 30, 153, 95, 91, 98, 203, 235, 236, 218,
+			218, 234, 107, 150, 23, 151, 22, 217, 165, 71, 217, 198, 67, 75,
+			108, 97, 245, 218, 163, 107, 203, 151, 31, 218, 96, 15, 173, 174,
+			44, 46, 173, 173, 179, 249, 171, 139, 108, 97, 245, 234, 198, 218,
+			242, 165, 235, 27, 171, 107, 235, 148, 149, 230, 215, 217, 242, 122,
+			9, 191, 204, 95, 125, 148, 45, 189, 246, 218, 218, 210, 250, 58,
+			91, 93, 99, 203, 87, 174, 173, 44, 47, 45, 178, 71, 230, 215,
+			214, 230, 175, 110, 44, 47, 173, 151, 217, 242, 213, 133, 149, 235,
+			139, 203, 87, 47, 151, 217, 165, 235, 27, 236, 234, 234, 6, 101,
+			43, 203, 87, 150, 55, 150, 22, 217, 198, 106, 25, 171, 221, 91,
+			142, 173, 62, 200, 174, 44, 173, 45, 60, 52, 127, 117, 99, 254,
+			210, 242, 202, 242, 198, 163, 88, 225, 131, 203, 27, 87, 161, 178,
+			7, 87, 215, 40, 155, 103, 215, 230, 215, 54, 150, 23, 174, 175,
+			204, 175, 177, 107, 215, 215, 174, 173, 174, 47, 49, 232, 217, 226,
+			242, 250, 194, 202, 252, 242, 149, 165, 197, 10, 91, 190, 202, 174,
+			174, 178, 165, 215, 44, 93, 221, 96, 235, 15, 205, 175, 172, 116,
+			119, 148, 178, 213, 71, 174, 46, 173, 65, 235, 179, 221, 100, 151,
+			150, 216, 202, 242, 252, 165, 149, 37, 168, 10, 251, 185, 184, 188,
+			182, 180, 176, 1, 29, 74, 127, 45, 44, 47, 46, 93, 221, 152,
+			95, 41, 83, 182, 126, 109, 105, 97, 121, 126, 165, 204, 150, 94,
+			187, 116, 229, 218, 202, 252, 218, 163, 101, 137, 116, 125, 233, 239,
+			95, 95, 186, 186, 177, 60, 191, 194, 22, 231, 175, 204, 95, 94,
+			90, 103, 83, 47, 70, 149, 107, 107, 171, 11, 215, 215, 150, 174,
+			64, 171, 87, 31, 100, 235, 215, 47, 173, 111, 44, 111, 92, 223,
+			88, 98, 151, 87, 87, 23, 145, 216, 235, 75, 107, 175, 89, 94,
+			88, 90, 191, 200, 86, 86, 215, 145, 96, 215, 215, 151, 202, 148,
+			45, 206, 111, 204, 99, 213, 215, 214, 86, 31, 92, 222, 88, 191,
+			8, 191, 47, 93, 95, 95, 70, 194, 45, 95, 221, 88, 90, 91,
+			187, 126, 109, 99, 121, 245, 234, 52, 123, 104, 245, 145, 165, 215,
+			44, 173, 177, 133, 249, 235, 235, 75, 139, 72, 225, 213, 171, 208,
+			91, 224, 149, 165, 213, 181, 71, 1, 45, 208, 1, 71, 160, 204,
+			30, 121, 104, 105, 227, 161, 165, 53, 32, 42, 82, 107, 30, 200,
+			176, 190, 177, 182, 188, 176, 145, 205, 182, 186, 198, 54, 86, 215,
+			54, 104, 166, 159, 236, 234, 210, 229, 149, 229, 203, 75, 87, 23,
+			150, 224, 243, 42, 160, 121, 100, 121, 125, 105, 154, 205, 175, 45,
+			175, 67, 134, 101, 172, 152, 61, 50, 255, 40, 91, 189, 142, 189,
+			134, 129, 186, 190, 190, 68, 197, 239, 12, 235, 150, 113, 60, 217,
+			242, 131, 108, 126, 241, 53, 203, 208, 114, 153, 251, 218, 234, 250,
+			250, 178, 100, 23, 36, 219, 194, 67, 146, 230, 90, 219, 97, 125,
+			19, 82, 219, 41, 245, 93, 68, 109, 231, 164, 248, 41, 18, 143,
+			247, 29, 197, 196, 163, 226, 167, 72, 60, 209, 183, 172, 244, 34,
+			248, 41, 18, 79, 246, 149, 49, 209, 16, 63, 69, 226, 93, 125,
+			179, 152, 40, 127, 138, 196, 201, 190, 18, 38, 82, 241, 83, 36,
+			78, 245, 29, 195, 196, 19, 226, 231, 239, 30, 65, 101, 43, 247,
+			172, 1, 75, 95, 241, 55, 142, 176, 121, 166, 151, 222, 172, 142,
+			229, 178, 86, 232, 5, 9, 138, 53, 175, 9, 203, 76, 141, 183,
+			120, 80, 227, 1, 138, 69, 55, 232, 136, 244, 167, 194, 0, 165,
+			137, 31, 86, 93, 159, 178, 170, 235, 243, 160, 230, 130, 90, 18,
+			128, 244, 175, 49, 23, 112, 85, 195, 182, 40, 39, 181, 3, 148,
+			165, 245, 200, 173, 166, 43, 134, 250, 0, 11, 2, 168, 10, 8,
+			195, 138, 25, 250, 66, 40, 162, 90, 43, 16, 121, 176, 148, 250,
+			110, 226, 237, 112, 16, 106, 110, 192, 120, 43, 172, 54, 152, 155,
+			176, 235, 27, 11, 172, 233, 213, 2, 148, 232, 97, 64, 217, 195,
+			110, 208, 134, 101, 224, 108, 153, 157, 189, 239, 222, 51, 101, 37,
+			168, 91, 81, 232, 243, 86, 226, 85, 217, 229, 136, 111, 135, 145,
+			231, 6, 186, 245, 108, 183, 225, 85, 27, 140, 223, 76, 56, 180,
+			9, 5, 244, 62, 185, 182, 220, 234, 141, 93, 55, 130, 28, 33,
+			235, 112, 55, 98, 97, 0, 250, 46, 46, 249, 77, 47, 104, 39,
+			28, 215, 75, 118, 207, 25, 221, 63, 63, 12, 182, 43, 108, 133,
+			187, 173, 180, 203, 17, 103, 165, 184, 201, 221, 136, 215, 74, 44,
+			14, 197, 2, 28, 132, 204, 231, 110, 139, 202, 108, 44, 113, 183,
+			124, 84, 203, 3, 206, 129, 174, 245, 48, 18, 170, 72, 11, 214,
+			86, 177, 160, 183, 99, 88, 149, 92, 246, 186, 185, 243, 51, 141,
+			176, 29, 49, 223, 11, 184, 27, 81, 134, 216, 95, 63, 245, 194,
+			74, 7, 140, 231, 44, 230, 156, 70, 41, 222, 224, 44, 66, 45,
+			199, 139, 113, 77, 96, 103, 206, 156, 57, 59, 131, 255, 109, 156,
+			57, 115, 1, 255, 123, 12, 186, 126, 223, 125, 247, 221, 55, 115,
+			118, 110, 230, 220, 217, 141, 185, 115, 23, 238, 190, 239, 194, 221,
+			247, 85, 238, 83, 255, 30, 171, 176, 75, 29, 10, 3, 153, 68,
+			94, 53, 129, 6, 38, 178, 139, 136, 189, 204, 118, 57, 227, 65,
+			220, 142, 228, 142, 98, 151, 227, 166, 162, 26, 6, 59, 60, 74,
+			196, 248, 138, 69, 137, 189, 110, 237, 193, 5, 202, 206, 157, 59,
+			119, 95, 218, 151, 221, 221, 221, 138, 199, 147, 58, 106, 136, 81,
+			189, 10, 255, 67, 142, 74, 114, 51, 153, 6, 141, 141, 51, 168,
+			57, 216, 134, 29, 2, 59, 206, 150, 110, 186, 205, 150, 207, 99,
+			74, 213, 79, 118, 246, 2, 91, 8, 155, 173, 118, 194, 51, 115,
+			1, 43, 188, 182, 186, 190, 252, 90, 246, 4, 80, 102, 106, 250,
+			137, 138, 84, 121, 210, 76, 90, 249, 188, 40, 190, 164, 202, 115,
+			204, 147, 77, 57, 192, 83, 88, 252, 234, 245, 149, 149, 233, 233,
+			125, 243, 33, 191, 79, 157, 153, 190, 152, 105, 211, 220, 139, 181,
+			105, 155, 39, 128, 37, 172, 215, 220, 78, 166, 109, 113, 18, 181,
+			171, 9, 86, 176, 227, 250, 44, 217, 145, 53, 118, 101, 191, 43,
+			217, 41, 51, 108, 208, 197, 111, 183, 75, 59, 149, 100, 7, 160,
+			23, 234, 145, 200, 212, 142, 121, 149, 157, 98, 103, 207, 156, 233,
+			238, 225, 185, 91, 246, 240, 17, 47, 56, 55, 199, 158, 184, 204,
+			147, 245, 78, 156, 240, 38, 124, 158, 143, 31, 244, 124, 190, 209,
+			61, 16, 15, 46, 175, 44, 109, 44, 95, 89, 98, 245, 68, 54,
+			227, 86, 101, 238, 170, 39, 170, 165, 215, 151, 175, 110, 220, 115,
+			158, 37, 94, 245, 70, 204, 238, 103, 83, 83, 83, 34, 101, 186,
+			158, 84, 106, 187, 15, 121, 219, 141, 69, 55, 193, 82, 211, 236,
+			149, 175, 100, 231, 230, 166, 217, 155, 24, 126, 91, 9, 119, 213,
+			39, 69, 183, 217, 89, 54, 15, 237, 173, 133, 187, 49, 162, 132,
+			201, 114, 246, 204, 153, 140, 12, 139, 43, 58, 131, 144, 82, 103,
+			239, 217, 59, 141, 52, 54, 40, 126, 246, 158, 243, 231, 207, 223,
+			123, 238, 158, 51, 169, 216, 216, 226, 245, 48, 226, 236, 122, 224,
+			221, 84, 88, 238, 187, 247, 76, 47, 150, 202, 183, 55, 152, 83,
+			162, 255, 108, 106, 74, 16, 101, 22, 7, 11, 254, 77, 179, 153,
+			108, 115, 94, 132, 131, 1, 15, 144, 75, 225, 57, 153, 193, 131,
+			12, 48, 221, 197, 0, 231, 111, 201, 0, 15, 187, 59, 46, 123,
+			66, 12, 100, 165, 42, 76, 0, 144, 229, 138, 231, 251, 94, 156,
+			97, 0, 144, 166, 172, 137, 169, 236, 126, 118, 235, 2, 47, 192,
+			230, 236, 254, 52, 181, 18, 240, 221, 75, 109, 207, 175, 241, 104,
+			106, 26, 58, 182, 46, 41, 36, 171, 16, 132, 153, 22, 184, 224,
+			31, 228, 185, 42, 250, 238, 5, 9, 244, 92, 230, 20, 93, 151,
+			221, 70, 10, 76, 87, 182, 0, 51, 182, 37, 165, 193, 221, 47,
+			66, 131, 229, 32, 78, 220, 32, 169, 4, 225, 110, 166, 219, 50,
+			149, 5, 225, 46, 187, 159, 117, 229, 121, 193, 158, 166, 13, 127,
+			241, 46, 7, 225, 110, 101, 155, 39, 75, 192, 108, 34, 109, 106,
+			58, 211, 243, 238, 222, 203, 204, 0, 76, 221, 162, 167, 247, 220,
+			178, 167, 114, 188, 148, 158, 193, 174, 117, 146, 134, 216, 72, 116,
+			49, 90, 118, 160, 166, 166, 123, 185, 240, 50, 79, 22, 210, 113,
+			159, 154, 70, 89, 255, 240, 250, 234, 85, 118, 197, 109, 181, 188,
+			96, 155, 82, 182, 28, 136, 20, 177, 107, 47, 163, 26, 144, 161,
+			83, 167, 133, 75, 93, 151, 226, 34, 150, 14, 169, 51, 80, 92,
+			128, 190, 165, 245, 71, 84, 5, 186, 139, 11, 106, 75, 89, 160,
+			17, 169, 80, 89, 233, 141, 160, 55, 60, 61, 243, 198, 102, 24,
+			36, 141, 167, 103, 222, 88, 115, 59, 79, 111, 188, 17, 22, 239,
+			167, 47, 188, 177, 233, 5, 79, 95, 120, 99, 204, 171, 79, 191,
+			174, 242, 70, 80, 151, 96, 202, 62, 253, 250, 199, 74, 148, 237,
+			54, 120, 196, 153, 40, 13, 136, 92, 127, 215, 237, 196, 140, 223,
+			4, 13, 14, 54, 123, 66, 23, 168, 131, 22, 80, 243, 182, 189,
+			36, 6, 165, 198, 231, 76, 214, 84, 102, 88, 85, 153, 50, 81,
+			89, 153, 97, 109, 101, 92, 108, 177, 74, 212, 75, 158, 226, 81,
+			56, 211, 114, 107, 53, 177, 125, 76, 118, 67, 133, 141, 187, 213,
+			134, 208, 201, 148, 30, 7, 250, 159, 20, 41, 101, 169, 65, 193,
+			66, 190, 29, 178, 118, 11, 213, 4, 85, 116, 202, 171, 240, 138,
+			76, 60, 187, 191, 182, 55, 93, 166, 88, 127, 216, 18, 152, 69,
+			77, 165, 199, 74, 44, 110, 215, 235, 222, 77, 208, 71, 189, 170,
+			11, 10, 22, 140, 34, 240, 1, 106, 162, 83, 165, 235, 27, 11,
+			165, 233, 139, 93, 169, 84, 40, 140, 111, 104, 123, 17, 175, 85,
+			216, 60, 19, 230, 47, 193, 12, 49, 238, 201, 189, 167, 120, 196,
+			226, 70, 216, 246, 107, 138, 148, 237, 152, 163, 54, 57, 229, 198,
+			186, 182, 26, 219, 234, 80, 104, 198, 52, 12, 64, 0, 187, 224,
+			64, 168, 52, 123, 89, 9, 8, 233, 118, 85, 213, 114, 163, 56,
+			173, 102, 139, 83, 134, 58, 29, 104, 56, 213, 42, 111, 37, 108,
+			43, 76, 26, 88, 39, 148, 21, 70, 3, 213, 135, 120, 79, 59,
+			64, 237, 13, 235, 245, 152, 39, 168, 174, 61, 24, 70, 140, 139,
+			185, 86, 102, 165, 185, 51, 103, 239, 133, 213, 225, 236, 221, 27,
+			103, 206, 94, 56, 119, 230, 194, 217, 187, 43, 103, 206, 62, 86,
+			146, 220, 29, 51, 132, 245, 242, 210, 114, 227, 132, 50, 204, 137,
+			245, 135, 65, 170, 55, 223, 93, 102, 128, 173, 34, 39, 144, 187,
+			227, 174, 87, 35, 175, 149, 148, 65, 219, 237, 82, 213, 92, 6,
+			203, 35, 11, 183, 158, 228, 213, 68, 104, 121, 160, 58, 10, 102,
+			23, 252, 136, 236, 15, 226, 170, 230, 70, 53, 202, 94, 151, 132,
+			203, 235, 171, 235, 56, 201, 166, 166, 247, 81, 80, 43, 205, 240,
+			41, 207, 247, 93, 156, 93, 60, 152, 185, 190, 62, 91, 11, 171,
+			241, 236, 35, 124, 107, 54, 109, 202, 236, 26, 175, 243, 136, 7,
+			85, 62, 123, 217, 15, 183, 92, 127, 115, 21, 219, 16, 207, 66,
+			131, 102, 51, 149, 76, 163, 237, 170, 17, 214, 42, 208, 25, 33,
+			105, 202, 56, 207, 69, 147, 216, 19, 160, 49, 2, 209, 43, 234,
+			199, 19, 170, 67, 194, 212, 173, 122, 203, 107, 116, 223, 46, 82,
+			246, 186, 39, 226, 36, 170, 99, 209, 76, 143, 194, 106, 92, 105,
+			9, 201, 6, 125, 153, 155, 245, 189, 173, 200, 141, 58, 168, 118,
+			87, 26, 73, 211, 63, 142, 191, 84, 217, 105, 180, 185, 80, 205,
+			200, 170, 146, 184, 197, 171, 108, 242, 228, 163, 51, 39, 155, 51,
+			39, 107, 27, 39, 31, 186, 112, 242, 202, 133, 147, 235, 149, 147,
+			245, 199, 38, 43, 108, 197, 187, 193, 119, 189, 152, 227, 54, 7,
+			8, 148, 142, 82, 59, 230, 2, 219, 195, 97, 205, 69, 102, 157,
+			140, 217, 235, 158, 88, 94, 95, 85, 74, 205, 131, 66, 88, 213,
+			36, 56, 53, 253, 196, 235, 167, 132, 165, 82, 202, 185, 39, 195,
+			154, 24, 9, 248, 49, 131, 251, 5, 183, 229, 225, 128, 168, 84,
+			177, 139, 16, 109, 157, 221, 139, 27, 251, 169, 42, 56, 57, 183,
+			120, 114, 110, 145, 178, 105, 32, 100, 184, 133, 22, 66, 87, 246,
+			51, 225, 17, 171, 186, 45, 156, 32, 97, 157, 109, 243, 128, 71,
+			174, 152, 106, 106, 154, 197, 66, 44, 107, 250, 227, 9, 192, 160,
+			56, 3, 32, 207, 26, 249, 81, 250, 1, 67, 158, 2, 144, 31,
+			48, 204, 241, 226, 15, 26, 108, 45, 221, 225, 42, 222, 15, 235,
+			200, 242, 72, 227, 216, 11, 170, 89, 45, 139, 238, 175, 102, 177,
+			43, 237, 56, 1, 94, 120, 161, 109, 17, 221, 111, 95, 244, 24,
+			243, 130, 170, 223, 142, 189, 29, 216, 40, 14, 171, 163, 5, 104,
+			95, 191, 2, 13, 0, 243, 7, 20, 104, 1, 232, 140, 209, 207,
+			27, 242, 120, 129, 188, 211, 48, 157, 226, 111, 27, 236, 106, 24,
+			204, 4, 124, 91, 236, 131, 187, 118, 211, 174, 218, 53, 194, 70,
+			114, 255, 221, 244, 85, 89, 80, 111, 48, 119, 92, 191, 205, 99,
+			97, 146, 76, 145, 161, 225, 52, 78, 60, 223, 103, 13, 119, 135,
+			179, 32, 91, 39, 162, 150, 5, 169, 60, 15, 194, 13, 122, 61,
+			140, 96, 99, 172, 172, 7, 189, 4, 147, 155, 198, 178, 252, 159,
+			238, 67, 20, 195, 198, 126, 42, 162, 24, 216, 237, 252, 176, 2,
+			45, 0, 11, 163, 250, 36, 227, 87, 223, 98, 81, 214, 123, 148,
+			81, 227, 49, 138, 135, 48, 186, 213, 89, 198, 21, 58, 10, 91,
+			132, 69, 157, 113, 157, 39, 206, 43, 40, 169, 123, 62, 159, 48,
+			152, 53, 53, 56, 119, 162, 247, 172, 162, 210, 93, 2, 207, 11,
+			214, 176, 68, 233, 15, 9, 29, 219, 231, 171, 227, 80, 18, 184,
+			77, 174, 142, 11, 225, 183, 51, 65, 251, 91, 110, 245, 134, 43,
+			207, 11, 7, 214, 20, 232, 220, 73, 169, 178, 181, 84, 59, 19,
+			22, 179, 166, 6, 214, 50, 41, 206, 105, 58, 218, 106, 111, 249,
+			94, 117, 51, 147, 141, 50, 107, 202, 94, 43, 136, 15, 139, 105,
+			230, 73, 122, 96, 151, 187, 55, 178, 89, 7, 49, 235, 8, 36,
+			103, 50, 46, 208, 161, 38, 143, 99, 119, 155, 111, 194, 242, 53,
+			65, 176, 247, 108, 79, 239, 123, 123, 62, 40, 75, 109, 116, 90,
+			220, 153, 167, 3, 60, 104, 55, 5, 6, 251, 22, 244, 91, 10,
+			218, 205, 94, 44, 121, 40, 38, 81, 244, 199, 60, 218, 241, 170,
+			124, 34, 135, 8, 38, 247, 32, 88, 23, 223, 123, 113, 168, 114,
+			206, 2, 29, 64, 211, 77, 236, 133, 193, 68, 63, 34, 57, 185,
+			207, 40, 114, 191, 214, 139, 34, 45, 231, 220, 67, 251, 133, 242,
+			17, 79, 228, 153, 49, 53, 56, 119, 120, 95, 70, 88, 21, 121,
+			214, 84, 102, 103, 153, 22, 196, 233, 199, 38, 44, 180, 155, 94,
+			80, 15, 39, 6, 16, 193, 209, 189, 29, 193, 140, 11, 97, 141,
+			47, 7, 245, 112, 109, 36, 238, 130, 157, 219, 105, 46, 238, 4,
+			137, 123, 115, 98, 8, 57, 68, 66, 165, 159, 207, 209, 3, 47,
+			133, 197, 46, 82, 187, 14, 189, 156, 48, 191, 21, 26, 136, 50,
+			221, 68, 204, 125, 155, 68, 156, 167, 131, 1, 143, 19, 94, 19,
+			28, 97, 189, 68, 158, 162, 162, 208, 94, 150, 34, 223, 22, 75,
+			189, 150, 30, 208, 77, 218, 68, 163, 147, 228, 205, 217, 23, 107,
+			73, 101, 73, 149, 91, 131, 98, 107, 35, 188, 11, 118, 22, 41,
+			13, 3, 30, 214, 55, 107, 188, 234, 79, 228, 111, 65, 165, 85,
+			200, 178, 135, 74, 161, 72, 173, 250, 206, 125, 41, 171, 245, 223,
+			130, 83, 174, 136, 73, 182, 135, 219, 174, 211, 17, 117, 168, 40,
+			123, 54, 128, 141, 168, 188, 104, 207, 214, 100, 49, 209, 177, 225,
+			40, 11, 58, 199, 169, 78, 216, 68, 182, 162, 40, 133, 134, 84,
+			226, 85, 183, 201, 139, 79, 209, 145, 110, 242, 56, 227, 212, 142,
+			19, 55, 18, 126, 17, 246, 154, 0, 156, 2, 181, 120, 80, 147,
+			231, 192, 240, 211, 249, 123, 105, 135, 45, 236, 240, 93, 123, 71,
+			180, 11, 115, 111, 191, 139, 247, 210, 225, 174, 14, 188, 212, 170,
+			75, 111, 162, 183, 237, 139, 218, 121, 45, 29, 111, 7, 218, 160,
+			202, 107, 155, 162, 170, 137, 63, 234, 191, 5, 207, 93, 207, 230,
+			22, 88, 214, 198, 218, 123, 19, 79, 13, 228, 191, 216, 95, 120,
+			230, 153, 103, 158, 49, 75, 191, 144, 163, 227, 251, 205, 153, 125,
+			167, 239, 237, 52, 39, 206, 248, 165, 219, 136, 132, 156, 121, 106,
+			251, 238, 22, 247, 39, 8, 51, 166, 70, 230, 78, 191, 164, 89,
+			89, 89, 129, 34, 107, 162, 164, 243, 42, 74, 164, 136, 6, 12,
+			167, 94, 26, 6, 152, 75, 107, 88, 206, 57, 68, 7, 224, 175,
+			224, 141, 28, 182, 57, 15, 9, 192, 23, 78, 145, 230, 133, 229,
+			156, 171, 165, 77, 195, 192, 88, 53, 94, 119, 219, 126, 178, 137,
+			106, 3, 50, 252, 192, 218, 144, 76, 124, 13, 164, 57, 71, 233,
+			160, 152, 85, 94, 80, 227, 55, 81, 122, 218, 107, 98, 162, 45,
+			67, 10, 84, 255, 100, 28, 6, 138, 53, 177, 10, 72, 192, 234,
+			239, 237, 21, 220, 71, 246, 239, 222, 158, 185, 52, 73, 15, 8,
+			109, 98, 83, 237, 58, 39, 70, 153, 49, 149, 95, 27, 17, 201,
+			171, 50, 181, 244, 51, 38, 37, 40, 88, 14, 208, 193, 141, 71,
+			175, 45, 109, 46, 174, 94, 191, 180, 178, 84, 48, 156, 17, 74,
+			49, 225, 193, 149, 213, 249, 141, 130, 169, 97, 52, 176, 21, 44,
+			93, 64, 88, 28, 11, 36, 155, 225, 220, 92, 193, 118, 10, 116,
+			72, 32, 88, 126, 237, 210, 226, 61, 231, 11, 185, 238, 148, 115,
+			115, 133, 126, 103, 152, 14, 96, 202, 165, 213, 213, 149, 66, 94,
+			227, 92, 223, 88, 91, 190, 122, 185, 48, 160, 113, 94, 94, 91,
+			189, 126, 173, 64, 53, 134, 43, 75, 235, 235, 243, 151, 151, 10,
+			131, 58, 199, 165, 71, 55, 150, 214, 11, 67, 93, 205, 58, 55,
+			87, 24, 214, 85, 44, 93, 189, 126, 165, 48, 226, 140, 210, 97,
+			81, 133, 106, 196, 129, 158, 164, 123, 206, 23, 10, 105, 67, 4,
+			150, 209, 174, 132, 123, 206, 23, 156, 210, 2, 181, 145, 13, 29,
+			135, 142, 172, 204, 95, 90, 90, 217, 92, 197, 19, 194, 249, 149,
+			130, 145, 166, 173, 45, 253, 253, 235, 203, 107, 75, 139, 5, 51,
+			155, 118, 109, 105, 126, 99, 105, 177, 96, 149, 170, 116, 124, 63,
+			129, 186, 239, 20, 202, 240, 130, 121, 11, 94, 64, 92, 189, 188,
+			80, 250, 3, 147, 142, 237, 179, 168, 236, 91, 201, 3, 212, 22,
+			188, 44, 150, 217, 233, 125, 87, 39, 228, 236, 61, 75, 45, 150,
+			203, 170, 26, 214, 45, 84, 13, 64, 177, 135, 97, 95, 191, 71,
+			248, 139, 245, 241, 158, 151, 178, 62, 98, 218, 183, 182, 8, 216,
+			251, 44, 2, 23, 233, 232, 30, 68, 47, 89, 24, 191, 217, 160,
+			19, 183, 34, 206, 139, 136, 68, 179, 75, 36, 94, 236, 165, 224,
+			177, 91, 15, 194, 158, 177, 254, 39, 6, 189, 125, 127, 149, 114,
+			223, 54, 188, 138, 230, 132, 9, 65, 142, 247, 222, 181, 235, 10,
+			126, 238, 29, 108, 89, 42, 187, 218, 91, 183, 210, 11, 69, 107,
+			246, 180, 244, 251, 77, 122, 219, 190, 200, 247, 109, 232, 17, 74,
+			189, 160, 213, 78, 132, 234, 36, 36, 241, 0, 166, 160, 240, 2,
+			41, 219, 78, 244, 119, 11, 191, 83, 145, 132, 25, 94, 145, 54,
+			148, 96, 67, 239, 188, 69, 79, 247, 48, 230, 25, 90, 168, 250,
+			30, 15, 146, 205, 56, 137, 184, 219, 244, 130, 109, 92, 106, 242,
+			23, 236, 186, 235, 199, 124, 237, 128, 248, 188, 174, 190, 66, 9,
+			100, 160, 40, 83, 34, 215, 85, 66, 124, 214, 37, 74, 239, 28,
+			160, 131, 25, 5, 220, 57, 70, 135, 158, 116, 119, 220, 77, 181,
+			169, 18, 148, 24, 132, 180, 107, 114, 99, 117, 134, 142, 99, 150,
+			176, 157, 240, 104, 179, 234, 187, 113, 140, 68, 203, 99, 86, 7,
+			190, 173, 194, 167, 5, 245, 197, 185, 155, 142, 97, 137, 102, 219,
+			79, 188, 150, 207, 55, 97, 155, 23, 227, 146, 163, 91, 54, 10,
+			57, 174, 200, 12, 208, 162, 216, 89, 164, 71, 176, 152, 180, 90,
+			240, 77, 254, 134, 182, 235, 199, 155, 110, 80, 219, 108, 184, 113,
+			99, 98, 28, 16, 92, 50, 39, 140, 181, 131, 144, 241, 178, 204,
+			183, 132, 217, 230, 131, 218, 67, 110, 220, 112, 46, 208, 219, 17,
+			139, 176, 65, 111, 86, 27, 188, 122, 99, 179, 157, 212, 95, 49,
+			113, 40, 91, 63, 182, 80, 88, 183, 22, 32, 203, 245, 164, 254,
+			10, 103, 157, 14, 193, 96, 52, 189, 167, 248, 102, 61, 140, 112,
+			13, 29, 217, 71, 52, 101, 40, 88, 89, 149, 5, 174, 132, 53,
+			126, 193, 94, 191, 182, 180, 180, 184, 54, 168, 176, 60, 24, 70,
+			192, 80, 219, 161, 38, 240, 160, 96, 168, 237, 80, 145, 247, 110,
+			58, 86, 173, 138, 62, 123, 213, 77, 185, 25, 139, 39, 10, 93,
+			196, 170, 86, 47, 139, 12, 146, 199, 99, 231, 62, 122, 91, 74,
+			172, 108, 193, 209, 61, 189, 236, 45, 122, 55, 29, 107, 117, 246,
+			22, 116, 186, 106, 108, 117, 122, 139, 221, 75, 199, 91, 141, 214,
+			222, 114, 167, 178, 229, 156, 86, 163, 213, 91, 240, 36, 238, 204,
+			35, 142, 38, 216, 137, 59, 178, 217, 51, 31, 156, 10, 45, 84,
+			171, 155, 60, 112, 183, 124, 190, 233, 70, 60, 112, 227, 137, 163,
+			152, 153, 36, 81, 155, 175, 141, 84, 171, 75, 248, 113, 30, 191,
+			57, 167, 232, 104, 184, 245, 100, 85, 112, 228, 102, 43, 226, 117,
+			239, 230, 196, 9, 36, 239, 1, 248, 128, 252, 120, 13, 147, 157,
+			105, 90, 168, 198, 13, 55, 106, 161, 72, 142, 91, 110, 149, 79,
+			156, 20, 89, 69, 250, 85, 149, 12, 51, 34, 222, 245, 234, 137,
+			194, 56, 41, 102, 4, 166, 73, 108, 83, 180, 0, 148, 232, 170,
+			120, 10, 179, 141, 180, 26, 173, 108, 189, 199, 233, 48, 228, 76,
+			43, 157, 22, 138, 91, 171, 145, 169, 241, 60, 189, 29, 50, 53,
+			121, 226, 214, 220, 196, 205, 228, 46, 99, 110, 32, 251, 21, 249,
+			177, 171, 157, 81, 123, 171, 163, 25, 107, 70, 180, 19, 210, 20,
+			107, 189, 108, 202, 121, 233, 2, 29, 202, 242, 189, 51, 64, 5,
+			231, 23, 12, 80, 130, 22, 86, 23, 65, 125, 121, 108, 169, 96,
+			130, 26, 181, 178, 188, 177, 180, 185, 118, 253, 234, 198, 242, 149,
+			165, 130, 149, 81, 236, 31, 38, 249, 187, 10, 147, 160, 53, 140,
+			116, 239, 212, 156, 87, 210, 59, 148, 89, 37, 230, 201, 230, 174,
+			23, 225, 132, 108, 186, 98, 113, 212, 252, 51, 46, 115, 173, 243,
+			228, 17, 47, 146, 230, 82, 103, 133, 30, 13, 194, 77, 101, 156,
+			222, 76, 13, 90, 155, 110, 181, 202, 227, 56, 20, 11, 161, 198,
+			114, 56, 8, 215, 101, 230, 116, 133, 152, 151, 89, 123, 216, 215,
+			186, 21, 251, 30, 162, 3, 77, 183, 181, 201, 131, 36, 234, 160,
+			126, 158, 95, 203, 55, 221, 214, 18, 192, 127, 39, 219, 164, 135,
+			73, 158, 20, 236, 135, 73, 222, 46, 228, 30, 38, 249, 92, 161,
+			255, 97, 146, 207, 23, 6, 30, 38, 249, 129, 2, 45, 125, 206,
+			162, 67, 89, 13, 30, 54, 68, 85, 92, 195, 12, 148, 114, 199,
+			95, 80, 223, 175, 44, 192, 226, 118, 33, 39, 212, 229, 53, 81,
+			18, 20, 11, 96, 63, 46, 212, 147, 252, 154, 132, 156, 203, 52,
+			247, 100, 140, 184, 115, 136, 123, 63, 107, 96, 6, 247, 195, 235,
+			136, 124, 224, 225, 245, 205, 171, 171, 107, 87, 230, 87, 214, 100,
+			113, 231, 32, 37, 190, 251, 84, 167, 123, 25, 196, 164, 151, 58,
+			44, 7, 41, 217, 229, 238, 141, 238, 197, 7, 147, 94, 198, 233,
+			49, 75, 109, 164, 151, 67, 169, 164, 88, 161, 207, 201, 83, 178,
+			176, 186, 6, 83, 164, 64, 135, 68, 234, 230, 181, 229, 165, 133,
+			165, 130, 89, 186, 155, 230, 4, 17, 96, 250, 104, 50, 20, 250,
+			36, 40, 113, 24, 234, 235, 245, 43, 151, 150, 214, 10, 230, 158,
+			193, 47, 197, 116, 40, 171, 153, 255, 221, 108, 207, 255, 149, 65,
+			7, 51, 154, 54, 168, 72, 174, 239, 135, 187, 155, 174, 239, 185,
+			177, 100, 13, 138, 73, 243, 144, 242, 82, 135, 238, 239, 104, 210,
+			216, 133, 92, 233, 131, 6, 45, 244, 170, 186, 61, 205, 52, 254,
+			123, 54, 179, 244, 126, 131, 142, 116, 235, 183, 61, 205, 59, 246,
+			223, 181, 121, 191, 111, 210, 225, 46, 173, 246, 165, 182, 238, 13,
+			116, 212, 171, 241, 102, 43, 76, 120, 80, 237, 108, 250, 124, 135,
+			251, 19, 37, 20, 26, 123, 205, 140, 93, 53, 84, 150, 211, 114,
+			43, 80, 236, 194, 216, 242, 226, 210, 149, 107, 171, 27, 75, 87,
+			23, 30, 221, 188, 126, 245, 213, 87, 87, 31, 185, 186, 86, 240,
+			122, 178, 189, 140, 211, 254, 26, 45, 244, 54, 202, 185, 131, 238,
+			215, 172, 66, 159, 51, 70, 15, 92, 93, 221, 92, 95, 94, 92,
+			218, 92, 122, 240, 193, 165, 133, 141, 117, 97, 9, 209, 185, 55,
+			186, 38, 120, 233, 31, 89, 116, 108, 159, 150, 56, 243, 114, 15,
+			35, 182, 85, 51, 47, 165, 245, 21, 208, 34, 174, 185, 81, 34,
+			183, 60, 211, 20, 168, 20, 36, 94, 221, 227, 145, 180, 48, 137,
+			141, 205, 129, 52, 93, 24, 153, 202, 212, 105, 133, 177, 151, 120,
+			59, 124, 211, 11, 148, 57, 10, 54, 58, 100, 173, 160, 190, 44,
+			7, 137, 206, 173, 78, 189, 50, 185, 109, 188, 242, 82, 80, 95,
+			116, 238, 99, 116, 168, 22, 182, 65, 251, 19, 249, 96, 237, 48,
+			214, 6, 69, 154, 206, 34, 245, 250, 212, 14, 54, 180, 54, 40,
+			210, 68, 150, 73, 122, 192, 221, 222, 142, 0, 185, 66, 36, 118,
+			42, 35, 58, 25, 51, 22, 31, 166, 121, 69, 7, 88, 188, 129,
+			18, 155, 45, 177, 253, 54, 167, 6, 214, 242, 129, 250, 120, 140,
+			14, 121, 241, 102, 106, 214, 55, 153, 57, 149, 95, 27, 244, 98,
+			109, 18, 45, 253, 19, 147, 142, 116, 31, 75, 56, 139, 52, 239,
+			135, 226, 206, 136, 60, 19, 155, 122, 145, 147, 140, 202, 138, 204,
+			191, 166, 75, 22, 63, 99, 208, 188, 74, 118, 110, 167, 164, 229,
+			38, 13, 68, 103, 95, 50, 11, 198, 26, 194, 144, 30, 183, 220,
+			0, 89, 64, 166, 3, 12, 227, 234, 115, 183, 134, 219, 160, 176,
+			217, 228, 65, 18, 171, 113, 149, 233, 11, 50, 217, 57, 77, 71,
+			147, 200, 245, 252, 174, 188, 4, 243, 22, 212, 7, 157, 249, 2,
+			61, 168, 240, 214, 120, 226, 86, 27, 188, 150, 22, 202, 161, 185,
+			227, 14, 153, 97, 81, 126, 87, 101, 75, 255, 222, 160, 163, 106,
+			227, 86, 211, 196, 186, 66, 169, 27, 4, 97, 146, 37, 215, 94,
+			86, 222, 83, 174, 50, 175, 11, 173, 101, 16, 20, 155, 148, 166,
+			95, 110, 73, 182, 163, 116, 80, 158, 57, 225, 193, 165, 216, 234,
+			83, 145, 4, 59, 60, 103, 156, 218, 91, 124, 219, 11, 164, 37,
+			89, 0, 202, 32, 67, 180, 65, 230, 210, 63, 216, 255, 118, 86,
+			161, 199, 220, 16, 63, 100, 60, 54, 243, 162, 247, 179, 82, 109,
+			181, 235, 106, 86, 177, 247, 106, 214, 26, 175, 251, 28, 143, 155,
+			31, 254, 222, 95, 51, 105, 191, 99, 79, 246, 253, 96, 191, 65,
+			63, 118, 0, 239, 101, 77, 126, 247, 94, 214, 119, 239, 101, 125,
+			247, 94, 214, 119, 239, 101, 125, 247, 94, 214, 119, 239, 101, 125,
+			251, 247, 178, 230, 62, 107, 202, 187, 240, 23, 216, 13, 30, 36,
+			97, 240, 247, 82, 193, 206, 166, 94, 141, 73, 236, 53, 110, 84,
+			115, 167, 41, 99, 151, 92, 152, 153, 97, 192, 194, 200, 219, 246,
+			2, 215, 223, 187, 0, 213, 120, 236, 109, 7, 232, 17, 202, 214,
+			221, 224, 73, 183, 195, 46, 55, 120, 211, 221, 117, 147, 50, 123,
+			152, 215, 235, 108, 145, 187, 65, 57, 117, 230, 140, 213, 181, 26,
+			105, 235, 201, 56, 176, 121, 62, 103, 98, 189, 220, 18, 82, 176,
+			198, 235, 94, 32, 5, 156, 190, 91, 47, 86, 100, 204, 29, 87,
+			96, 2, 236, 184, 190, 87, 203, 38, 43, 39, 197, 36, 114, 131,
+			216, 71, 79, 209, 154, 23, 241, 106, 226, 119, 208, 207, 148, 237,
+			227, 158, 68, 181, 20, 113, 131, 142, 148, 137, 94, 32, 150, 80,
+			16, 150, 83, 120, 195, 92, 229, 137, 132, 58, 4, 34, 141, 121,
+			205, 86, 24, 37, 241, 180, 190, 245, 54, 173, 111, 189, 157, 238,
+			91, 84, 119, 217, 224, 167, 72, 44, 167, 119, 217, 202, 250, 46,
+			219, 76, 223, 89, 117, 151, 13, 126, 138, 196, 74, 223, 189, 234,
+			210, 28, 252, 20, 137, 179, 233, 93, 54, 248, 41, 18, 207, 164,
+			55, 233, 206, 232, 155, 116, 231, 251, 14, 211, 239, 161, 102, 126,
+			0, 127, 22, 19, 214, 235, 10, 38, 22, 158, 45, 225, 96, 220,
+			244, 158, 146, 23, 170, 226, 22, 231, 53, 182, 197, 171, 46, 44,
+			225, 145, 86, 76, 102, 182, 128, 29, 40, 115, 253, 237, 48, 242,
+			146, 70, 51, 102, 181, 48, 152, 76, 216, 110, 24, 221, 96, 181,
+			54, 58, 137, 111, 133, 97, 18, 39, 145, 112, 55, 175, 80, 250,
+			164, 136, 107, 240, 138, 190, 11, 70, 241, 113, 28, 119, 165, 62,
+			176, 106, 216, 108, 121, 62, 186, 56, 6, 76, 28, 185, 236, 25,
+			155, 117, 158, 224, 146, 225, 122, 129, 242, 48, 22, 109, 167, 130,
+			1, 152, 151, 8, 143, 226, 108, 232, 131, 87, 228, 15, 210, 65,
+			21, 250, 224, 62, 115, 74, 69, 40, 32, 0, 233, 88, 6, 57,
+			199, 186, 111, 240, 206, 76, 44, 131, 251, 142, 30, 207, 196, 50,
+			184, 239, 174, 73, 58, 75, 77, 98, 56, 228, 254, 190, 199, 140,
+			226, 113, 182, 40, 89, 83, 220, 229, 107, 182, 124, 158, 240, 44,
+			219, 201, 22, 24, 134, 99, 221, 159, 63, 68, 239, 163, 132, 24,
+			208, 130, 87, 153, 135, 74, 101, 193, 152, 34, 236, 66, 246, 218,
+			94, 20, 134, 73, 70, 41, 73, 34, 206, 69, 11, 13, 108, 239,
+			171, 76, 13, 217, 142, 245, 170, 193, 81, 5, 25, 142, 245, 42,
+			231, 118, 5, 89, 142, 245, 170, 131, 69, 122, 10, 171, 52, 28,
+			235, 1, 243, 206, 210, 17, 25, 222, 161, 30, 134, 165, 50, 254,
+			169, 108, 185, 81, 169, 204, 120, 82, 173, 40, 172, 6, 129, 204,
+			26, 178, 29, 235, 1, 93, 7, 116, 228, 1, 231, 160, 130, 44,
+			199, 122, 224, 240, 17, 122, 30, 235, 48, 29, 235, 146, 121, 172,
+			56, 201, 174, 170, 213, 93, 14, 7, 78, 6, 225, 154, 173, 39,
+			181, 174, 205, 36, 80, 76, 67, 182, 99, 93, 210, 181, 65, 179,
+			47, 57, 135, 21, 100, 57, 214, 165, 163, 140, 254, 125, 172, 205,
+			114, 172, 69, 115, 170, 184, 200, 208, 243, 65, 212, 135, 215, 23,
+			209, 209, 47, 173, 84, 182, 65, 42, 59, 218, 173, 79, 232, 75,
+			168, 82, 233, 166, 88, 4, 112, 106, 200, 118, 172, 197, 193, 130,
+			130, 12, 199, 90, 28, 45, 41, 8, 106, 63, 57, 73, 159, 194,
+			166, 16, 199, 186, 108, 222, 85, 108, 246, 54, 101, 151, 187, 55,
+			94, 90, 67, 42, 194, 77, 93, 168, 74, 51, 168, 176, 131, 100,
+			109, 122, 219, 145, 16, 53, 97, 224, 119, 42, 108, 49, 4, 157,
+			15, 116, 35, 221, 102, 130, 149, 107, 200, 118, 172, 203, 186, 205,
+			196, 112, 172, 203, 163, 76, 65, 150, 99, 93, 62, 126, 146, 222,
+			131, 109, 182, 29, 235, 97, 179, 92, 156, 70, 109, 63, 9, 91,
+			51, 104, 151, 233, 146, 174, 89, 25, 172, 235, 179, 9, 20, 212,
+			80, 206, 177, 30, 30, 44, 42, 200, 112, 172, 135, 15, 77, 42,
+			200, 114, 172, 135, 79, 157, 198, 89, 103, 152, 57, 199, 122, 181,
+			57, 35, 63, 229, 8, 64, 10, 73, 14, 190, 201, 89, 103, 152,
+			57, 195, 177, 94, 125, 116, 74, 65, 150, 99, 189, 250, 116, 89,
+			34, 233, 119, 172, 21, 179, 34, 63, 245, 19, 128, 20, 146, 254,
+			156, 99, 173, 12, 30, 83, 144, 225, 88, 43, 165, 105, 5, 89,
+			142, 181, 82, 158, 145, 72, 242, 142, 117, 69, 35, 201, 19, 128,
+			20, 146, 124, 206, 177, 174, 12, 30, 85, 144, 225, 88, 87, 152,
+			66, 146, 183, 28, 235, 138, 70, 50, 224, 88, 171, 230, 113, 249,
+			105, 128, 0, 164, 144, 12, 228, 28, 107, 117, 80, 77, 195, 1,
+			195, 177, 86, 239, 80, 157, 27, 176, 28, 107, 245, 88, 137, 254,
+			137, 129, 88, 168, 99, 93, 55, 103, 139, 95, 48, 216, 134, 32,
+			52, 247, 107, 74, 180, 197, 250, 138, 71, 215, 154, 227, 110, 193,
+			90, 3, 44, 164, 215, 223, 204, 222, 165, 66, 217, 163, 97, 27,
+			117, 232, 216, 173, 115, 140, 14, 211, 132, 221, 11, 14, 36, 15,
+			18, 47, 226, 178, 26, 181, 108, 53, 220, 168, 9, 98, 52, 106,
+			7, 137, 215, 228, 148, 213, 219, 129, 188, 181, 226, 37, 29, 197,
+			202, 233, 50, 17, 179, 153, 25, 76, 202, 182, 42, 189, 125, 139,
+			33, 104, 96, 229, 151, 251, 196, 38, 94, 96, 10, 67, 63, 214,
+			44, 68, 9, 116, 91, 67, 57, 199, 186, 62, 168, 36, 10, 53,
+			28, 235, 122, 241, 148, 130, 44, 199, 186, 62, 83, 161, 175, 71,
+			106, 13, 58, 214, 163, 230, 157, 197, 107, 184, 98, 8, 143, 79,
+			61, 233, 51, 2, 87, 124, 110, 183, 228, 180, 147, 222, 219, 120,
+			151, 24, 179, 205, 149, 80, 241, 16, 192, 185, 146, 110, 214, 32,
+			1, 252, 26, 178, 29, 235, 81, 45, 136, 6, 13, 199, 122, 212,
+			153, 80, 144, 229, 88, 143, 30, 58, 66, 39, 169, 73, 76, 135,
+			188, 190, 239, 105, 163, 120, 168, 107, 41, 144, 122, 12, 222, 119,
+			145, 75, 0, 200, 178, 215, 231, 239, 64, 254, 49, 97, 9, 120,
+			220, 60, 132, 248, 76, 20, 234, 143, 203, 154, 77, 20, 234, 143,
+			203, 154, 77, 20, 234, 143, 75, 161, 110, 162, 80, 127, 252, 96,
+			81, 34, 49, 28, 235, 9, 243, 148, 252, 4, 82, 251, 9, 141,
+			196, 200, 57, 214, 19, 146, 147, 77, 148, 218, 79, 176, 147, 10,
+			178, 28, 235, 137, 169, 105, 137, 196, 116, 44, 87, 78, 7, 19,
+			133, 177, 171, 145, 192, 164, 117, 53, 18, 168, 206, 149, 211, 193,
+			68, 97, 236, 202, 233, 128, 64, 213, 60, 45, 63, 129, 24, 173,
+			106, 36, 86, 206, 177, 170, 82, 68, 152, 40, 70, 171, 135, 238,
+			82, 16, 148, 155, 62, 37, 145, 16, 199, 170, 73, 17, 97, 162,
+			92, 171, 105, 36, 36, 231, 88, 53, 41, 34, 76, 148, 107, 53,
+			41, 34, 76, 148, 107, 181, 211, 101, 58, 4, 72, 172, 62, 135,
+			212, 205, 27, 150, 248, 102, 1, 245, 234, 116, 130, 30, 162, 57,
+			128, 128, 236, 219, 228, 72, 105, 8, 118, 163, 218, 175, 126, 132,
+			246, 139, 143, 4, 190, 14, 165, 176, 237, 88, 219, 195, 78, 10,
+			27, 142, 181, 61, 54, 145, 194, 150, 99, 109, 31, 58, 172, 145,
+			27, 142, 213, 32, 135, 74, 67, 108, 233, 230, 94, 228, 48, 60,
+			141, 12, 114, 88, 86, 27, 25, 228, 48, 68, 141, 177, 219, 83,
+			216, 114, 172, 198, 193, 34, 29, 150, 200, 77, 199, 122, 146, 204,
+			234, 207, 64, 172, 39, 51, 232, 96, 168, 158, 28, 46, 165, 176,
+			225, 88, 79, 30, 63, 149, 194, 150, 99, 61, 57, 83, 145, 148,
+			182, 29, 203, 215, 99, 14, 18, 221, 215, 148, 6, 137, 238, 203,
+			233, 104, 162, 68, 247, 139, 106, 204, 65, 162, 251, 122, 204, 115,
+			142, 21, 152, 179, 242, 19, 72, 244, 64, 35, 1, 137, 30, 104,
+			198, 1, 137, 30, 48, 197, 167, 32, 209, 3, 221, 146, 126, 199,
+			106, 153, 138, 29, 64, 162, 183, 52, 18, 144, 232, 45, 221, 18,
+			144, 232, 173, 226, 49, 5, 89, 142, 213, 58, 113, 146, 126, 204,
+			192, 65, 55, 28, 210, 54, 111, 90, 197, 247, 25, 12, 253, 172,
+			64, 44, 40, 211, 21, 75, 220, 109, 25, 18, 42, 174, 176, 181,
+			125, 82, 81, 92, 194, 186, 170, 204, 14, 32, 190, 80, 72, 198,
+			12, 239, 142, 73, 19, 176, 184, 150, 175, 151, 240, 216, 109, 234,
+			13, 75, 6, 177, 204, 212, 116, 59, 104, 40, 98, 225, 14, 143,
+			124, 183, 37, 197, 140, 105, 193, 64, 183, 233, 29, 146, 107, 80,
+			25, 220, 185, 5, 75, 10, 117, 111, 71, 15, 179, 80, 248, 118,
+			52, 215, 8, 149, 111, 71, 179, 164, 80, 250, 118, 52, 75, 162,
+			218, 183, 123, 11, 150, 20, 122, 222, 110, 6, 57, 176, 228, 110,
+			6, 57, 180, 116, 87, 179, 164, 208, 246, 118, 181, 240, 201, 59,
+			86, 199, 44, 203, 241, 128, 101, 180, 163, 71, 14, 150, 209, 206,
+			224, 132, 130, 12, 199, 234, 28, 156, 84, 144, 229, 88, 157, 83,
+			167, 233, 247, 226, 200, 193, 58, 250, 38, 243, 100, 177, 157, 210,
+			79, 172, 78, 104, 32, 82, 151, 37, 247, 142, 143, 26, 158, 253,
+			134, 2, 182, 127, 219, 222, 14, 15, 132, 5, 10, 10, 139, 69,
+			137, 167, 60, 17, 6, 85, 165, 210, 152, 184, 124, 191, 73, 55,
+			126, 192, 118, 172, 55, 105, 241, 11, 203, 247, 155, 28, 197, 201,
+			176, 124, 191, 169, 116, 130, 14, 82, 144, 58, 246, 247, 244, 125,
+			159, 97, 160, 112, 7, 177, 246, 61, 249, 35, 116, 149, 18, 98,
+			153, 125, 14, 249, 31, 12, 243, 66, 113, 94, 236, 109, 228, 229,
+			198, 36, 140, 184, 90, 212, 113, 143, 82, 11, 121, 12, 91, 165,
+			136, 87, 195, 237, 192, 123, 138, 179, 6, 143, 120, 133, 173, 115,
+			174, 21, 211, 97, 106, 3, 66, 130, 24, 53, 152, 3, 112, 240,
+			78, 5, 26, 0, 30, 61, 167, 64, 11, 192, 123, 238, 163, 143,
+			65, 195, 108, 135, 188, 197, 48, 15, 22, 175, 176, 5, 244, 44,
+			139, 113, 103, 133, 106, 30, 103, 213, 118, 156, 132, 205, 180, 77,
+			65, 202, 236, 177, 14, 126, 167, 89, 60, 219, 46, 16, 182, 150,
+			221, 7, 200, 135, 39, 68, 197, 54, 180, 227, 45, 198, 240, 168,
+			2, 77, 0, 111, 155, 160, 231, 40, 72, 243, 220, 247, 27, 125,
+			95, 54, 140, 226, 201, 174, 133, 50, 213, 69, 240, 138, 155, 30,
+			67, 188, 174, 6, 251, 172, 239, 55, 242, 135, 233, 8, 37, 132,
+			144, 62, 39, 247, 86, 195, 124, 206, 176, 176, 2, 130, 183, 217,
+			222, 106, 244, 15, 210, 117, 154, 35, 242, 58, 219, 219, 13, 50,
+			94, 92, 96, 103, 196, 197, 86, 197, 82, 48, 139, 163, 40, 140,
+			226, 10, 101, 171, 81, 13, 182, 241, 49, 219, 229, 94, 36, 190,
+			53, 60, 24, 28, 175, 234, 250, 176, 137, 143, 195, 0, 116, 149,
+			3, 180, 95, 32, 53, 16, 235, 129, 52, 193, 132, 4, 103, 140,
+			142, 200, 106, 13, 135, 252, 160, 65, 198, 116, 6, 67, 36, 140,
+			164, 9, 38, 36, 140, 58, 116, 87, 150, 48, 29, 242, 78, 131,
+			140, 21, 183, 217, 213, 48, 97, 143, 121, 219, 143, 185, 219, 234,
+			122, 117, 133, 233, 27, 104, 90, 64, 37, 238, 13, 206, 206, 158,
+			97, 91, 157, 132, 199, 21, 134, 177, 9, 51, 14, 196, 204, 171,
+			83, 166, 239, 158, 101, 20, 30, 223, 187, 193, 253, 78, 166, 51,
+			226, 146, 92, 166, 105, 162, 41, 163, 142, 238, 140, 229, 144, 31,
+			50, 200, 184, 206, 0, 178, 245, 135, 178, 221, 183, 76, 72, 112,
+			198, 116, 103, 136, 67, 222, 253, 29, 235, 204, 185, 185, 151, 222,
+			25, 96, 143, 119, 103, 59, 3, 202, 216, 187, 179, 157, 177, 29,
+			242, 30, 131, 220, 166, 51, 216, 6, 38, 20, 210, 4, 19, 18,
+			198, 198, 117, 137, 156, 67, 126, 36, 91, 34, 103, 96, 66, 90,
+			34, 103, 66, 66, 166, 68, 191, 67, 222, 107, 16, 71, 103, 232,
+			55, 48, 97, 56, 77, 48, 33, 161, 48, 170, 75, 228, 29, 242,
+			163, 89, 18, 231, 13, 76, 72, 73, 156, 55, 33, 193, 25, 163,
+			159, 51, 100, 145, 1, 135, 124, 16, 56, 251, 223, 27, 108, 195,
+			221, 158, 169, 113, 223, 107, 122, 160, 221, 234, 3, 207, 10, 101,
+			151, 163, 176, 157, 222, 214, 79, 79, 224, 81, 221, 5, 249, 153,
+			42, 197, 94, 32, 239, 95, 87, 216, 67, 225, 46, 223, 225, 81,
+			89, 152, 241, 206, 81, 216, 176, 250, 92, 159, 8, 196, 234, 86,
+			182, 184, 234, 184, 197, 245, 213, 108, 148, 107, 40, 126, 183, 177,
+			226, 93, 220, 91, 136, 187, 184, 110, 80, 163, 44, 137, 184, 155,
+			200, 143, 82, 94, 187, 49, 107, 7, 24, 158, 80, 166, 100, 134,
+			115, 192, 192, 78, 166, 195, 57, 96, 66, 194, 232, 24, 157, 145,
+			84, 160, 14, 249, 144, 65, 110, 47, 29, 97, 43, 60, 216, 78,
+			26, 251, 211, 65, 151, 167, 6, 230, 79, 199, 142, 154, 144, 48,
+			118, 27, 61, 46, 17, 14, 58, 228, 35, 64, 214, 49, 118, 149,
+			239, 2, 81, 118, 120, 132, 43, 253, 92, 6, 205, 160, 129, 185,
+			210, 118, 13, 154, 144, 48, 154, 10, 128, 33, 135, 252, 88, 150,
+			105, 134, 12, 76, 72, 7, 116, 200, 132, 4, 39, 101, 154, 97,
+			135, 252, 120, 86, 100, 12, 27, 152, 144, 50, 205, 176, 9, 9,
+			133, 148, 149, 71, 28, 242, 81, 131, 220, 161, 51, 140, 24, 152,
+			48, 154, 38, 152, 144, 48, 126, 187, 46, 113, 192, 33, 255, 56,
+			91, 226, 128, 129, 9, 105, 137, 3, 38, 36, 140, 223, 78, 39,
+			101, 137, 130, 67, 254, 137, 65, 110, 43, 221, 1, 115, 50, 238,
+			154, 202, 194, 112, 167, 74, 22, 12, 204, 153, 118, 176, 96, 66,
+			130, 51, 174, 81, 141, 58, 228, 159, 190, 36, 84, 163, 6, 230,
+			76, 81, 141, 154, 144, 128, 180, 2, 161, 111, 56, 185, 159, 48,
+			204, 127, 174, 133, 62, 8, 215, 159, 48, 250, 135, 232, 41, 172,
+			9, 244, 39, 242, 191, 24, 228, 142, 98, 241, 150, 66, 95, 85,
+			134, 234, 18, 100, 118, 210, 4, 19, 18, 110, 83, 68, 3, 133,
+			137, 252, 179, 148, 104, 168, 3, 65, 66, 90, 2, 100, 249, 63,
+			203, 150, 48, 29, 242, 124, 182, 4, 160, 120, 62, 91, 66, 228,
+			184, 237, 118, 92, 51, 9, 180, 247, 19, 134, 121, 72, 116, 7,
+			87, 246, 79, 168, 149, 157, 224, 141, 231, 79, 24, 131, 163, 10,
+			52, 0, 116, 110, 87, 160, 5, 224, 193, 162, 196, 100, 56, 228,
+			167, 12, 243, 176, 252, 104, 16, 4, 21, 38, 195, 6, 112, 176,
+			160, 64, 204, 60, 122, 135, 2, 45, 0, 139, 135, 36, 38, 211,
+			33, 63, 157, 182, 9, 36, 250, 79, 167, 152, 64, 38, 254, 116,
+			138, 9, 170, 253, 105, 99, 84, 181, 9, 22, 140, 159, 134, 54,
+			189, 223, 64, 84, 150, 67, 126, 14, 244, 141, 183, 27, 108, 185,
+			206, 244, 93, 40, 24, 154, 152, 39, 242, 172, 50, 224, 188, 166,
+			52, 186, 152, 39, 21, 6, 121, 49, 198, 3, 126, 198, 115, 75,
+			85, 82, 132, 187, 72, 203, 106, 251, 116, 128, 106, 190, 190, 141,
+			83, 102, 217, 187, 60, 160, 184, 167, 119, 125, 42, 170, 47, 22,
+			193, 230, 105, 48, 7, 224, 224, 1, 5, 26, 0, 22, 198, 21,
+			136, 125, 185, 99, 130, 254, 172, 137, 93, 35, 14, 249, 148, 97,
+			178, 226, 63, 53, 209, 88, 167, 246, 248, 208, 90, 30, 180, 155,
+			216, 228, 88, 182, 210, 139, 187, 206, 64, 49, 86, 65, 167, 197,
+			69, 79, 213, 23, 12, 248, 16, 37, 242, 106, 185, 203, 38, 43,
+			147, 101, 80, 12, 189, 152, 213, 219, 190, 223, 153, 121, 67, 219,
+			245, 189, 186, 135, 235, 232, 106, 210, 224, 145, 136, 96, 176, 112,
+			250, 244, 12, 172, 134, 44, 174, 134, 24, 193, 133, 69, 109, 95,
+			174, 146, 234, 220, 180, 238, 201, 195, 95, 92, 15, 68, 96, 145,
+			186, 23, 197, 194, 118, 36, 174, 145, 138, 22, 43, 237, 11, 218,
+			77, 211, 94, 33, 209, 221, 168, 218, 224, 53, 12, 206, 18, 164,
+			249, 80, 167, 229, 1, 6, 186, 144, 209, 74, 32, 49, 10, 67,
+			140, 81, 39, 221, 151, 167, 53, 213, 137, 160, 156, 6, 109, 0,
+			53, 147, 195, 50, 254, 41, 195, 81, 220, 71, 44, 0, 239, 60,
+			74, 255, 1, 18, 221, 118, 200, 47, 25, 230, 209, 98, 75, 198,
+			241, 80, 234, 233, 11, 211, 153, 109, 113, 47, 216, 150, 113, 234,
+			144, 124, 203, 64, 86, 42, 46, 250, 239, 240, 90, 247, 206, 193,
+			13, 2, 30, 193, 226, 164, 185, 78, 183, 221, 38, 216, 0, 13,
+			98, 123, 116, 219, 65, 161, 248, 37, 195, 41, 42, 208, 2, 240,
+			200, 157, 244, 39, 5, 199, 228, 28, 242, 107, 134, 121, 162, 248,
+			17, 193, 49, 65, 187, 201, 35, 175, 170, 24, 69, 27, 250, 186,
+			172, 121, 9, 191, 153, 164, 241, 6, 165, 245, 87, 244, 12, 149,
+			33, 105, 42, 222, 10, 67, 159, 187, 64, 135, 82, 18, 181, 121,
+			9, 24, 190, 132, 206, 119, 37, 153, 67, 6, 119, 235, 169, 71,
+			222, 39, 20, 213, 192, 23, 220, 19, 76, 193, 100, 228, 113, 213,
+			109, 9, 210, 184, 65, 135, 237, 186, 157, 105, 85, 25, 168, 106,
+			61, 136, 22, 116, 126, 209, 44, 225, 55, 130, 57, 217, 171, 238,
+			103, 103, 231, 94, 129, 60, 36, 51, 85, 40, 219, 88, 93, 92,
+			157, 18, 7, 140, 211, 23, 196, 57, 226, 204, 61, 231, 165, 166,
+			248, 128, 34, 112, 142, 32, 205, 52, 104, 3, 168, 233, 13, 234,
+			216, 175, 25, 206, 81, 5, 90, 0, 150, 142, 211, 127, 40, 132,
+			79, 191, 67, 62, 99, 152, 199, 138, 59, 48, 203, 80, 100, 192,
+			70, 48, 150, 230, 202, 26, 191, 41, 66, 64, 224, 133, 73, 197,
+			1, 217, 147, 164, 78, 139, 79, 198, 44, 189, 184, 76, 133, 113,
+			158, 101, 205, 179, 158, 176, 239, 129, 46, 43, 198, 197, 77, 68,
+			9, 205, 50, 253, 4, 155, 161, 65, 27, 64, 45, 63, 65, 63,
+			252, 140, 49, 170, 196, 118, 191, 5, 224, 81, 70, 191, 33, 186,
+			144, 119, 200, 111, 65, 23, 190, 100, 136, 208, 56, 41, 103, 171,
+			22, 136, 136, 63, 72, 116, 41, 85, 97, 67, 188, 231, 4, 173,
+			34, 37, 13, 5, 145, 16, 177, 134, 43, 178, 186, 172, 164, 111,
+			136, 150, 228, 206, 15, 166, 113, 138, 191, 44, 251, 132, 159, 38,
+			101, 20, 11, 202, 118, 165, 250, 7, 2, 166, 146, 149, 70, 94,
+			50, 9, 218, 102, 173, 93, 149, 174, 24, 194, 231, 4, 80, 77,
+			198, 162, 253, 91, 29, 21, 28, 6, 165, 149, 135, 49, 112, 170,
+			110, 147, 251, 11, 110, 156, 206, 181, 60, 193, 206, 107, 208, 6,
+			80, 143, 61, 168, 201, 191, 149, 202, 137, 188, 5, 224, 157, 76,
+			46, 97, 3, 14, 249, 109, 195, 60, 33, 63, 14, 16, 4, 21,
+			166, 129, 28, 128, 131, 106, 245, 3, 77, 243, 183, 141, 9, 197,
+			69, 3, 22, 128, 165, 227, 244, 167, 6, 16, 21, 117, 200, 127,
+			51, 204, 147, 197, 31, 31, 64, 18, 70, 109, 158, 10, 27, 29,
+			183, 168, 164, 172, 243, 165, 10, 123, 4, 164, 163, 254, 162, 217,
+			68, 229, 0, 18, 129, 54, 236, 86, 111, 196, 76, 204, 235, 42,
+			103, 160, 174, 70, 53, 159, 199, 242, 164, 12, 10, 73, 115, 177,
+			64, 216, 115, 229, 22, 69, 93, 218, 22, 81, 64, 45, 134, 91,
+			28, 3, 190, 225, 65, 182, 224, 238, 36, 164, 44, 246, 182, 81,
+			162, 132, 44, 244, 107, 170, 121, 85, 105, 6, 192, 65, 214, 173,
+			65, 228, 232, 217, 143, 138, 83, 55, 183, 129, 224, 148, 115, 38,
+			102, 66, 123, 199, 24, 96, 165, 184, 19, 36, 13, 158, 120, 213,
+			146, 248, 94, 150, 46, 71, 123, 218, 231, 37, 49, 139, 67, 31,
+			29, 177, 112, 230, 76, 113, 183, 218, 80, 77, 210, 93, 20, 133,
+			182, 121, 18, 99, 9, 168, 72, 87, 33, 106, 152, 174, 176, 117,
+			149, 34, 27, 21, 51, 126, 211, 139, 147, 244, 100, 77, 157, 84,
+			160, 217, 71, 52, 169, 38, 14, 206, 212, 181, 51, 148, 111, 243,
+			215, 150, 247, 67, 166, 245, 139, 168, 198, 35, 216, 89, 212, 19,
+			88, 27, 124, 159, 149, 34, 238, 250, 178, 167, 177, 138, 44, 165,
+			214, 75, 177, 157, 41, 239, 25, 53, 101, 228, 169, 194, 102, 72,
+			84, 28, 243, 166, 27, 64, 143, 132, 79, 94, 153, 202, 232, 58,
+			65, 24, 204, 68, 188, 197, 113, 207, 214, 141, 87, 133, 222, 194,
+			49, 210, 163, 166, 55, 111, 48, 163, 112, 127, 70, 123, 194, 127,
+			233, 170, 106, 60, 113, 61, 31, 56, 109, 183, 193, 181, 139, 22,
+			74, 134, 221, 40, 76, 120, 134, 159, 97, 37, 9, 194, 4, 15,
+			82, 188, 88, 249, 77, 180, 99, 94, 111, 251, 200, 28, 81, 216,
+			14, 106, 51, 73, 228, 225, 121, 126, 230, 252, 93, 28, 192, 32,
+			89, 170, 97, 16, 123, 49, 58, 73, 179, 93, 78, 81, 14, 239,
+			233, 83, 239, 224, 50, 215, 143, 195, 50, 227, 59, 28, 134, 50,
+			108, 111, 55, 164, 54, 4, 99, 167, 194, 148, 137, 224, 82, 61,
+			116, 216, 144, 211, 147, 163, 187, 151, 235, 251, 29, 121, 238, 234,
+			6, 137, 246, 90, 72, 82, 99, 92, 213, 13, 38, 97, 78, 114,
+			223, 103, 94, 93, 219, 161, 188, 236, 217, 77, 24, 97, 252, 220,
+			160, 221, 44, 179, 56, 132, 150, 32, 107, 200, 145, 80, 227, 73,
+			123, 59, 1, 140, 129, 91, 233, 185, 30, 190, 142, 21, 23, 130,
+			24, 22, 115, 196, 119, 183, 203, 217, 230, 117, 152, 235, 71, 220,
+			173, 117, 244, 48, 210, 20, 9, 42, 137, 79, 116, 223, 14, 127,
+			66, 203, 78, 74, 80, 106, 105, 208, 6, 80, 107, 182, 176, 21,
+			254, 111, 70, 65, 73, 60, 106, 1, 88, 58, 65, 75, 20, 180,
+			177, 220, 31, 27, 125, 127, 98, 24, 197, 241, 46, 211, 156, 234,
+			205, 32, 181, 8, 232, 57, 127, 108, 228, 15, 163, 176, 181, 97,
+			15, 243, 85, 181, 95, 176, 113, 15, 243, 85, 85, 181, 141, 123,
+			152, 175, 42, 177, 109, 227, 30, 230, 171, 106, 15, 99, 227, 30,
+			230, 171, 106, 15, 99, 195, 102, 226, 107, 74, 108, 219, 184, 135,
+			249, 90, 138, 201, 200, 1, 40, 197, 182, 141, 123, 152, 175, 41,
+			177, 109, 227, 30, 230, 107, 32, 182, 39, 169, 73, 114, 78, 238,
+			207, 141, 190, 119, 154, 70, 241, 96, 182, 19, 65, 170, 153, 203,
+			158, 128, 6, 241, 231, 70, 94, 236, 124, 114, 208, 147, 175, 171,
+			158, 228, 176, 39, 95, 87, 245, 231, 176, 39, 95, 87, 61, 201,
+			97, 79, 190, 174, 122, 146, 195, 158, 124, 93, 245, 36, 7, 61,
+			249, 134, 97, 86, 228, 71, 232, 201, 55, 82, 76, 208, 147, 111,
+			24, 131, 199, 21, 136, 153, 79, 76, 43, 208, 2, 176, 60, 35,
+			49, 153, 14, 249, 166, 97, 170, 188, 176, 237, 248, 102, 138, 9,
+			116, 202, 111, 26, 131, 170, 17, 80, 237, 55, 141, 59, 238, 84,
+			160, 5, 224, 177, 18, 125, 30, 20, 208, 156, 213, 231, 228, 254,
+			161, 105, 62, 107, 90, 197, 15, 154, 251, 28, 141, 40, 133, 84,
+			88, 217, 50, 135, 24, 210, 236, 182, 223, 193, 8, 15, 146, 200,
+			235, 57, 5, 1, 34, 239, 123, 4, 210, 115, 2, 194, 174, 130,
+			168, 145, 238, 186, 98, 198, 214, 188, 56, 241, 130, 106, 34, 52,
+			135, 23, 140, 197, 34, 170, 116, 19, 92, 78, 161, 180, 58, 48,
+			97, 113, 187, 218, 80, 159, 80, 96, 185, 173, 86, 20, 182, 34,
+			207, 77, 120, 246, 133, 20, 108, 177, 60, 5, 247, 130, 228, 220,
+			28, 101, 181, 176, 233, 122, 129, 156, 70, 57, 11, 198, 248, 31,
+			154, 244, 16, 61, 76, 115, 0, 2, 131, 188, 197, 220, 115, 62,
+			3, 123, 251, 156, 56, 50, 132, 207, 67, 105, 130, 13, 9, 195,
+			78, 154, 96, 64, 194, 216, 68, 154, 96, 65, 194, 161, 195, 186,
+			6, 195, 33, 223, 103, 226, 33, 205, 126, 53, 0, 35, 125, 95,
+			182, 6, 216, 216, 127, 95, 182, 6, 3, 17, 140, 221, 158, 38,
+			88, 144, 112, 176, 40, 78, 199, 114, 192, 19, 111, 51, 205, 51,
+			251, 159, 142, 221, 146, 5, 186, 63, 244, 178, 2, 5, 157, 14,
+			231, 150, 252, 222, 203, 14, 12, 244, 103, 87, 120, 175, 220, 146,
+			53, 104, 134, 55, 4, 3, 195, 6, 253, 109, 166, 230, 118, 216,
+			160, 191, 205, 28, 60, 172, 64, 3, 192, 35, 167, 21, 136, 61,
+			171, 204, 82, 31, 251, 73, 28, 242, 14, 211, 60, 81, 124, 60,
+			173, 46, 109, 225, 45, 79, 148, 34, 46, 52, 218, 125, 15, 141,
+			232, 126, 167, 70, 162, 114, 34, 170, 211, 160, 13, 160, 150, 21,
+			176, 169, 125, 135, 41, 55, 42, 57, 220, 212, 190, 195, 44, 29,
+			167, 103, 169, 73, 250, 157, 220, 187, 204, 190, 31, 53, 123, 253,
+			199, 68, 59, 213, 89, 136, 16, 93, 82, 106, 193, 166, 225, 93,
+			102, 94, 40, 187, 253, 192, 148, 239, 54, 165, 212, 234, 71, 38,
+			124, 183, 106, 73, 63, 178, 224, 187, 85, 75, 250, 145, 1, 223,
+			109, 74, 169, 213, 143, 236, 247, 110, 83, 74, 173, 126, 96, 190,
+			247, 152, 210, 134, 212, 143, 204, 246, 158, 20, 19, 176, 218, 123,
+			76, 185, 115, 233, 71, 70, 123, 143, 41, 109, 72, 253, 200, 102,
+			239, 49, 165, 13, 169, 31, 164, 214, 123, 77, 115, 74, 126, 132,
+			193, 120, 111, 138, 9, 164, 214, 123, 205, 65, 213, 98, 168, 246,
+			189, 230, 225, 227, 10, 180, 0, 188, 107, 146, 158, 160, 38, 201,
+			59, 185, 247, 155, 125, 255, 179, 105, 20, 111, 239, 162, 142, 188,
+			140, 46, 9, 2, 155, 129, 247, 155, 249, 59, 177, 242, 60, 16,
+			228, 3, 138, 32, 121, 36, 200, 7, 84, 229, 121, 36, 200, 7,
+			20, 65, 242, 72, 144, 15, 40, 130, 228, 145, 32, 31, 80, 4,
+			201, 67, 203, 62, 104, 154, 101, 249, 17, 8, 242, 193, 20, 19,
+			136, 241, 15, 154, 131, 76, 129, 152, 249, 216, 164, 2, 45, 0,
+			79, 157, 150, 152, 76, 135, 124, 216, 52, 239, 146, 31, 129, 32,
+			31, 78, 49, 1, 65, 62, 108, 14, 30, 84, 160, 1, 96, 241,
+			152, 2, 45, 0, 79, 156, 164, 103, 168, 73, 6, 156, 220, 143,
+			155, 125, 159, 48, 141, 98, 169, 199, 199, 36, 105, 132, 53, 21,
+			212, 48, 75, 28, 216, 223, 252, 184, 153, 63, 130, 13, 25, 0,
+			226, 124, 84, 17, 103, 0, 137, 243, 81, 213, 144, 1, 36, 206,
+			71, 21, 113, 6, 144, 56, 31, 85, 196, 25, 64, 226, 124, 20,
+			136, 243, 35, 6, 162, 50, 28, 242, 156, 105, 30, 43, 254, 143,
+			6, 91, 14, 208, 55, 51, 168, 41, 55, 77, 180, 187, 224, 44,
+			195, 13, 51, 143, 133, 17, 105, 95, 139, 203, 174, 219, 97, 110,
+			76, 217, 190, 225, 148, 180, 17, 166, 204, 182, 218, 137, 186, 9,
+			81, 7, 157, 53, 220, 235, 91, 35, 26, 10, 3, 245, 92, 218,
+			43, 224, 220, 231, 210, 94, 25, 216, 110, 231, 176, 2, 45, 0,
+			143, 50, 73, 31, 211, 33, 31, 51, 205, 146, 252, 8, 3, 245,
+			177, 20, 147, 105, 3, 168, 49, 1, 5, 62, 102, 58, 71, 20,
+			104, 1, 200, 142, 73, 76, 150, 67, 62, 110, 154, 39, 229, 71,
+			144, 101, 31, 79, 49, 129, 44, 251, 184, 57, 56, 161, 64, 3,
+			192, 131, 76, 129, 88, 246, 248, 9, 186, 132, 152, 136, 67, 158,
+			55, 205, 123, 139, 247, 178, 101, 117, 61, 47, 6, 77, 85, 236,
+			222, 152, 136, 5, 2, 155, 21, 17, 93, 67, 165, 43, 255, 105,
+			85, 39, 17, 120, 52, 104, 3, 40, 181, 194, 1, 20, 82, 207,
+			155, 5, 213, 4, 16, 82, 207, 67, 19, 36, 152, 7, 240, 228,
+			61, 10, 236, 7, 240, 204, 221, 178, 129, 182, 67, 126, 114, 191,
+			6, 138, 104, 36, 123, 27, 40, 211, 123, 27, 104, 19, 196, 163,
+			65, 68, 171, 27, 8, 106, 231, 79, 166, 13, 180, 45, 0, 117,
+			3, 237, 60, 128, 186, 129, 118, 63, 128, 103, 238, 166, 207, 143,
+			80, 147, 80, 39, 247, 31, 204, 190, 127, 103, 25, 115, 87, 217,
+			253, 127, 251, 127, 148, 201, 59, 169, 116, 238, 223, 13, 179, 37,
+			216, 188, 106, 207, 184, 212, 99, 82, 220, 20, 130, 197, 3, 67,
+			130, 150, 84, 120, 26, 230, 38, 226, 142, 92, 118, 102, 80, 246,
+			36, 238, 39, 244, 45, 182, 56, 179, 56, 137, 45, 1, 94, 71,
+			74, 66, 88, 132, 212, 214, 181, 198, 98, 223, 219, 110, 36, 126,
+			135, 213, 188, 58, 6, 247, 77, 96, 157, 130, 173, 168, 219, 81,
+			214, 44, 214, 240, 96, 143, 95, 199, 157, 88, 77, 170, 92, 77,
+			55, 240, 90, 109, 31, 55, 135, 218, 92, 164, 6, 4, 180, 51,
+			229, 33, 0, 136, 246, 247, 16, 112, 227, 61, 30, 2, 156, 157,
+			146, 164, 201, 224, 146, 189, 204, 228, 85, 43, 110, 135, 227, 170,
+			43, 173, 8, 194, 81, 91, 5, 175, 45, 139, 55, 62, 178, 187,
+			52, 84, 18, 208, 117, 34, 181, 128, 42, 101, 163, 89, 17, 1,
+			223, 185, 91, 19, 27, 96, 116, 176, 128, 15, 76, 196, 203, 21,
+			123, 186, 160, 171, 145, 169, 133, 187, 234, 250, 62, 175, 177, 253,
+			46, 10, 87, 178, 6, 63, 148, 63, 56, 158, 90, 124, 201, 115,
+			137, 106, 20, 198, 49, 154, 6, 246, 146, 128, 61, 194, 133, 213,
+			92, 108, 237, 52, 182, 36, 100, 173, 80, 140, 130, 176, 206, 101,
+			104, 132, 145, 179, 119, 57, 195, 40, 241, 204, 205, 152, 50, 202,
+			64, 42, 193, 29, 226, 245, 25, 168, 20, 199, 48, 22, 77, 219,
+			226, 60, 160, 130, 110, 226, 188, 55, 14, 49, 79, 6, 59, 8,
+			99, 36, 123, 5, 131, 207, 43, 167, 38, 117, 44, 143, 188, 210,
+			61, 218, 242, 42, 86, 181, 17, 198, 28, 77, 62, 226, 34, 89,
+			124, 129, 178, 83, 184, 203, 87, 25, 69, 203, 208, 66, 168, 92,
+			109, 112, 191, 160, 253, 58, 96, 136, 125, 14, 106, 185, 47, 47,
+			243, 177, 48, 162, 140, 177, 48, 218, 118, 3, 239, 41, 121, 191,
+			47, 140, 196, 177, 221, 205, 22, 143, 60, 60, 130, 246, 85, 29,
+			101, 36, 164, 116, 14, 146, 77, 190, 251, 204, 153, 51, 103, 0,
+			75, 210, 136, 208, 90, 128, 239, 170, 40, 99, 191, 60, 153, 232,
+			132, 109, 113, 143, 44, 125, 72, 5, 146, 106, 218, 149, 90, 12,
+			3, 147, 35, 139, 168, 177, 21, 90, 116, 201, 22, 84, 94, 160,
+			215, 91, 210, 239, 60, 110, 72, 242, 99, 247, 133, 43, 58, 250,
+			195, 106, 108, 80, 83, 246, 201, 32, 144, 157, 137, 7, 154, 41,
+			159, 105, 186, 158, 28, 213, 173, 118, 125, 102, 27, 3, 118, 207,
+			232, 17, 156, 137, 248, 182, 23, 39, 81, 39, 115, 61, 7, 59,
+			31, 42, 13, 53, 227, 170, 166, 29, 222, 214, 189, 102, 203, 239,
+			168, 11, 124, 208, 117, 188, 72, 135, 113, 187, 81, 213, 197, 75,
+			44, 128, 71, 68, 6, 247, 118, 248, 204, 2, 107, 249, 237, 109,
+			47, 152, 198, 174, 116, 21, 217, 229, 91, 177, 151, 112, 54, 229,
+			213, 153, 187, 227, 122, 190, 187, 229, 243, 105, 233, 18, 28, 241,
+			201, 152, 5, 33, 32, 195, 19, 62, 32, 251, 205, 150, 143, 242,
+			40, 220, 69, 178, 195, 92, 11, 240, 139, 164, 124, 179, 194, 174,
+			199, 109, 180, 216, 192, 119, 100, 31, 44, 140, 225, 233, 217, 158,
+			46, 85, 208, 193, 89, 248, 48, 193, 6, 131, 239, 25, 39, 121,
+			137, 20, 16, 133, 65, 134, 38, 216, 44, 49, 188, 91, 29, 214,
+			106, 39, 42, 62, 189, 20, 23, 113, 123, 107, 166, 203, 215, 9,
+			79, 57, 196, 140, 80, 211, 59, 22, 183, 94, 88, 88, 23, 108,
+			135, 215, 39, 99, 201, 180, 226, 249, 156, 11, 240, 229, 91, 188,
+			44, 139, 129, 194, 49, 245, 120, 168, 196, 148, 60, 41, 244, 98,
+			150, 180, 35, 16, 182, 237, 68, 46, 5, 66, 126, 128, 76, 128,
+			225, 80, 186, 159, 102, 196, 152, 39, 172, 221, 146, 156, 225, 182,
+			147, 176, 233, 38, 94, 21, 41, 236, 198, 120, 49, 75, 90, 253,
+			21, 143, 8, 165, 145, 26, 14, 249, 15, 102, 126, 76, 248, 166,
+			83, 208, 26, 63, 107, 154, 199, 139, 159, 55, 216, 58, 79, 132,
+			57, 17, 159, 222, 144, 209, 136, 228, 67, 11, 24, 33, 137, 199,
+			153, 245, 73, 30, 1, 120, 177, 178, 10, 202, 134, 81, 214, 242,
+			221, 42, 174, 129, 151, 58, 234, 28, 170, 156, 113, 223, 86, 136,
+			61, 124, 98, 160, 38, 180, 63, 101, 39, 8, 235, 9, 72, 56,
+			47, 200, 108, 237, 181, 233, 172, 171, 188, 182, 179, 5, 97, 212,
+			196, 110, 227, 41, 171, 96, 11, 253, 50, 149, 218, 249, 75, 141,
+			85, 40, 32, 20, 181, 227, 207, 42, 125, 132, 162, 118, 252, 89,
+			165, 253, 81, 212, 142, 63, 107, 58, 119, 42, 208, 2, 240, 88,
+			137, 254, 177, 137, 36, 51, 28, 242, 7, 166, 121, 186, 248, 57,
+			147, 45, 132, 65, 18, 133, 254, 222, 211, 201, 221, 200, 109, 181,
+			120, 36, 72, 137, 196, 203, 146, 78, 94, 143, 173, 244, 248, 179,
+			187, 137, 204, 138, 164, 84, 22, 72, 185, 212, 247, 20, 152, 4,
+			132, 73, 170, 83, 79, 77, 171, 157, 2, 40, 220, 187, 28, 16,
+			196, 226, 153, 50, 125, 235, 35, 179, 74, 136, 117, 95, 47, 155,
+			221, 45, 89, 174, 179, 125, 162, 190, 73, 51, 14, 72, 2, 117,
+			84, 12, 75, 15, 158, 106, 162, 169, 89, 241, 136, 58, 28, 162,
+			93, 23, 243, 20, 223, 202, 51, 105, 47, 136, 189, 154, 92, 107,
+			197, 170, 161, 72, 134, 209, 233, 4, 50, 61, 94, 160, 247, 255,
+			65, 58, 94, 160, 247, 255, 65, 58, 94, 6, 142, 136, 115, 151,
+			2, 45, 0, 167, 79, 209, 127, 108, 225, 120, 153, 14, 249, 146,
+			105, 94, 44, 254, 136, 5, 61, 19, 97, 201, 84, 15, 52, 187,
+			163, 254, 36, 135, 8, 111, 166, 251, 126, 230, 28, 129, 197, 188,
+			229, 226, 207, 10, 80, 70, 220, 64, 19, 242, 0, 116, 196, 148,
+			196, 82, 178, 148, 165, 65, 25, 151, 104, 57, 113, 247, 37, 57,
+			149, 198, 116, 182, 209, 104, 139, 215, 81, 98, 174, 207, 235, 65,
+			227, 8, 194, 228, 212, 254, 100, 83, 244, 66, 74, 137, 3, 121,
+			244, 153, 221, 47, 196, 95, 133, 165, 14, 96, 123, 202, 138, 154,
+			148, 227, 23, 205, 48, 106, 18, 118, 177, 223, 139, 241, 93, 150,
+			237, 232, 75, 226, 59, 121, 231, 72, 140, 27, 236, 134, 190, 148,
+			14, 50, 236, 61, 190, 164, 54, 9, 20, 183, 100, 95, 50, 11,
+			199, 21, 104, 1, 120, 215, 148, 2, 243, 0, 78, 95, 80, 96,
+			63, 128, 231, 239, 195, 39, 137, 41, 228, 253, 178, 105, 46, 21,
+			15, 9, 133, 79, 10, 198, 90, 200, 241, 204, 161, 33, 92, 149,
+			68, 73, 216, 205, 125, 57, 109, 132, 101, 3, 168, 27, 1, 187,
+			185, 47, 155, 133, 25, 5, 34, 226, 51, 115, 10, 204, 3, 120,
+			110, 17, 157, 134, 0, 196, 207, 231, 23, 232, 23, 132, 224, 32,
+			14, 249, 51, 211, 124, 85, 241, 255, 50, 229, 193, 181, 62, 233,
+			203, 112, 226, 220, 11, 179, 162, 214, 243, 41, 170, 67, 187, 194,
+			250, 125, 179, 202, 69, 159, 64, 183, 196, 163, 34, 23, 212, 238,
+			132, 55, 91, 168, 35, 53, 93, 177, 195, 144, 43, 131, 139, 135,
+			80, 215, 55, 30, 156, 121, 5, 197, 179, 124, 22, 243, 55, 180,
+			241, 132, 16, 247, 222, 242, 213, 31, 113, 50, 72, 153, 12, 233,
+			150, 185, 15, 42, 26, 85, 11, 181, 174, 92, 161, 41, 131, 185,
+			25, 255, 98, 169, 202, 9, 230, 18, 175, 188, 196, 105, 229, 221,
+			117, 199, 234, 44, 74, 142, 79, 195, 133, 172, 140, 215, 235, 160,
+			146, 200, 206, 165, 26, 39, 214, 237, 131, 150, 34, 239, 52, 233,
+			49, 36, 130, 208, 26, 180, 1, 212, 99, 8, 219, 225, 63, 51,
+			11, 74, 90, 192, 118, 248, 207, 204, 233, 211, 10, 204, 3, 88,
+			190, 95, 129, 253, 0, 222, 251, 74, 186, 2, 35, 72, 250, 156,
+			220, 55, 76, 243, 111, 76, 171, 248, 74, 166, 99, 152, 104, 193,
+			39, 207, 208, 246, 187, 82, 171, 118, 104, 177, 247, 148, 110, 39,
+			122, 54, 127, 195, 236, 31, 163, 139, 192, 47, 194, 179, 249, 47,
+			77, 50, 82, 58, 175, 145, 167, 183, 77, 177, 56, 32, 148, 91,
+			169, 178, 126, 185, 71, 104, 213, 194, 150, 76, 165, 43, 243, 95,
+			154, 100, 32, 77, 48, 33, 97, 104, 152, 94, 150, 245, 24, 14,
+			249, 43, 147, 56, 197, 156, 184, 16, 90, 154, 69, 239, 220, 52,
+			14, 201, 106, 11, 159, 116, 212, 46, 162, 82, 46, 137, 137, 174,
+			156, 57, 169, 244, 129, 254, 43, 83, 58, 52, 82, 233, 3, 253,
+			87, 102, 97, 84, 87, 101, 58, 228, 175, 77, 114, 91, 233, 222,
+			108, 151, 106, 92, 62, 135, 35, 57, 107, 197, 75, 184, 14, 99,
+			209, 51, 164, 10, 51, 52, 250, 175, 77, 233, 230, 73, 165, 79,
+			243, 95, 155, 99, 227, 104, 148, 161, 32, 45, 158, 177, 204, 139,
+			114, 220, 108, 130, 160, 98, 1, 59, 7, 160, 60, 98, 162, 104,
+			112, 120, 198, 154, 56, 161, 64, 11, 192, 73, 37, 75, 236, 60,
+			128, 90, 150, 216, 253, 0, 158, 191, 143, 126, 78, 204, 226, 156,
+			67, 158, 181, 204, 82, 241, 255, 52, 83, 141, 233, 114, 216, 163,
+			47, 137, 103, 8, 191, 53, 125, 105, 185, 206, 66, 17, 243, 164,
+			188, 7, 169, 92, 60, 179, 161, 48, 186, 99, 126, 160, 62, 58,
+			131, 62, 35, 91, 110, 204, 179, 186, 136, 86, 184, 240, 168, 148,
+			181, 220, 164, 81, 102, 94, 93, 71, 251, 168, 136, 162, 25, 95,
+			143, 108, 169, 56, 113, 19, 193, 4, 123, 117, 5, 137, 6, 15,
+			93, 246, 199, 210, 219, 150, 174, 194, 234, 110, 162, 150, 22, 122,
+			6, 231, 8, 18, 89, 131, 54, 128, 122, 189, 207, 25, 0, 74,
+			59, 31, 69, 247, 160, 103, 173, 163, 199, 232, 111, 17, 28, 160,
+			126, 135, 188, 199, 50, 47, 22, 127, 153, 176, 117, 225, 212, 44,
+			35, 157, 170, 165, 56, 238, 182, 180, 120, 129, 88, 197, 125, 55,
+			216, 110, 187, 219, 252, 1, 198, 74, 50, 242, 105, 73, 23, 17,
+			254, 137, 120, 174, 171, 66, 141, 224, 203, 156, 29, 152, 143, 137,
+			87, 5, 85, 157, 173, 93, 91, 96, 177, 120, 132, 15, 45, 64,
+			29, 44, 148, 214, 132, 55, 163, 57, 101, 168, 145, 118, 139, 249,
+			120, 79, 51, 216, 148, 14, 75, 80, 19, 161, 94, 92, 95, 238,
+			216, 226, 233, 10, 101, 151, 123, 59, 181, 203, 165, 249, 4, 55,
+			69, 55, 188, 160, 38, 30, 150, 21, 234, 135, 122, 65, 40, 12,
+			50, 14, 226, 120, 210, 231, 70, 126, 71, 185, 68, 163, 181, 167,
+			55, 90, 19, 221, 167, 50, 65, 141, 93, 225, 1, 32, 92, 40,
+			50, 174, 232, 94, 192, 234, 238, 78, 136, 174, 83, 98, 166, 203,
+			134, 203, 87, 114, 186, 151, 179, 44, 69, 197, 54, 244, 214, 36,
+			141, 240, 33, 200, 50, 149, 34, 73, 251, 185, 133, 12, 189, 226,
+			42, 140, 173, 226, 229, 218, 26, 151, 27, 119, 177, 7, 143, 97,
+			253, 216, 195, 6, 194, 231, 157, 226, 246, 213, 171, 122, 9, 108,
+			34, 240, 212, 158, 55, 133, 251, 65, 59, 93, 84, 250, 9, 178,
+			149, 6, 109, 0, 245, 162, 210, 111, 0, 168, 181, 147, 126, 11,
+			64, 173, 157, 244, 231, 1, 212, 18, 165, 31, 57, 244, 252, 125,
+			82, 114, 229, 29, 242, 35, 150, 169, 86, 156, 60, 65, 80, 213,
+			147, 183, 1, 212, 245, 228, 13, 0, 11, 39, 21, 104, 1, 56,
+			117, 74, 129, 136, 234, 244, 43, 21, 216, 15, 224, 61, 23, 101,
+			61, 3, 14, 121, 111, 42, 33, 7, 8, 130, 170, 158, 1, 27,
+			64, 93, 207, 128, 1, 160, 238, 207, 128, 5, 160, 238, 207, 64,
+			30, 64, 221, 159, 129, 126, 0, 117, 127, 168, 67, 126, 212, 50,
+			85, 35, 40, 65, 80, 213, 67, 109, 0, 117, 61, 176, 29, 253,
+			81, 171, 160, 36, 49, 181, 0, 156, 156, 86, 96, 30, 192, 83,
+			170, 201, 180, 31, 192, 187, 47, 208, 175, 137, 189, 235, 160, 67,
+			62, 100, 153, 115, 197, 63, 52, 216, 114, 220, 21, 72, 68, 49,
+			226, 3, 148, 137, 39, 126, 128, 5, 67, 33, 191, 18, 55, 218,
+			230, 9, 72, 221, 164, 30, 70, 77, 233, 104, 5, 107, 55, 111,
+			122, 9, 228, 79, 47, 84, 104, 155, 45, 21, 202, 254, 14, 143,
+			58, 168, 47, 102, 21, 88, 52, 104, 121, 137, 22, 210, 106, 201,
+			246, 59, 204, 219, 14, 194, 136, 215, 46, 170, 236, 80, 158, 50,
+			159, 187, 113, 146, 117, 54, 195, 235, 20, 106, 37, 199, 154, 84,
+			23, 132, 26, 230, 103, 182, 174, 131, 4, 123, 173, 65, 27, 64,
+			77, 207, 65, 3, 192, 66, 81, 129, 22, 128, 71, 142, 42, 48,
+			15, 32, 59, 171, 192, 126, 0, 79, 159, 161, 223, 131, 228, 28,
+			114, 200, 143, 89, 230, 189, 197, 55, 48, 17, 157, 57, 86, 206,
+			67, 120, 248, 132, 161, 154, 245, 214, 85, 6, 17, 217, 47, 142,
+			139, 180, 170, 162, 17, 144, 163, 75, 153, 136, 189, 178, 189, 71,
+			95, 2, 100, 11, 167, 79, 235, 174, 13, 17, 108, 129, 6, 109,
+			0, 117, 215, 240, 186, 133, 37, 79, 9, 168, 57, 100, 1, 120,
+			92, 205, 132, 161, 60, 128, 119, 221, 163, 192, 126, 0, 207, 222,
+			77, 127, 72, 176, 202, 176, 67, 254, 169, 101, 78, 23, 223, 156,
+			49, 115, 132, 202, 18, 198, 170, 114, 35, 36, 34, 64, 75, 193,
+			225, 33, 140, 6, 60, 220, 9, 193, 102, 55, 83, 132, 238, 215,
+			161, 158, 53, 190, 34, 36, 150, 240, 109, 82, 178, 74, 119, 119,
+			152, 96, 163, 52, 104, 3, 168, 23, 185, 97, 3, 64, 71, 205,
+			192, 97, 11, 192, 187, 166, 232, 50, 246, 103, 196, 33, 63, 97,
+			153, 83, 197, 139, 76, 199, 145, 70, 114, 238, 105, 211, 69, 85,
+			109, 172, 252, 183, 229, 194, 174, 155, 49, 66, 16, 151, 6, 109,
+			0, 117, 51, 70, 12, 0, 157, 146, 2, 45, 0, 79, 78, 210,
+			119, 10, 101, 232, 128, 67, 126, 210, 50, 79, 20, 191, 215, 204,
+			88, 123, 216, 250, 174, 87, 79, 178, 107, 27, 78, 13, 188, 17,
+			182, 215, 14, 4, 122, 223, 130, 114, 18, 69, 167, 143, 136, 131,
+			74, 4, 172, 63, 89, 153, 20, 186, 126, 59, 168, 241, 40, 174,
+			134, 17, 215, 1, 230, 132, 127, 73, 168, 6, 77, 249, 132, 199,
+			179, 113, 167, 185, 21, 250, 49, 85, 155, 77, 233, 185, 153, 164,
+			91, 139, 88, 12, 174, 80, 127, 202, 194, 37, 11, 155, 168, 173,
+			248, 210, 239, 86, 28, 59, 208, 23, 170, 70, 215, 162, 8, 120,
+			128, 32, 77, 52, 104, 3, 168, 233, 121, 192, 0, 80, 219, 150,
+			14, 88, 0, 30, 59, 142, 110, 240, 212, 44, 56, 228, 127, 133,
+			97, 109, 165, 92, 218, 106, 180, 94, 42, 119, 66, 214, 61, 12,
+			64, 247, 225, 202, 69, 57, 78, 94, 204, 96, 135, 216, 209, 109,
+			47, 16, 108, 128, 6, 109, 0, 117, 219, 11, 6, 128, 154, 23,
+			10, 22, 128, 39, 39, 233, 255, 46, 230, 216, 168, 67, 254, 133,
+			101, 158, 44, 62, 111, 224, 118, 34, 67, 111, 52, 36, 136, 200,
+			132, 58, 44, 28, 178, 108, 88, 223, 191, 209, 186, 137, 52, 109,
+			227, 158, 81, 212, 159, 186, 21, 86, 84, 55, 179, 110, 204, 48,
+			49, 128, 25, 18, 30, 53, 211, 88, 66, 186, 17, 186, 243, 163,
+			4, 59, 160, 65, 27, 64, 221, 249, 81, 3, 64, 71, 137, 210,
+			81, 11, 192, 210, 9, 250, 107, 162, 243, 142, 67, 62, 101, 153,
+			149, 226, 255, 246, 183, 232, 188, 138, 27, 175, 169, 64, 247, 142,
+			212, 139, 82, 33, 181, 184, 101, 9, 65, 53, 37, 94, 2, 33,
+			28, 130, 157, 209, 160, 13, 160, 38, 132, 99, 0, 232, 40, 93,
+			192, 177, 0, 60, 61, 67, 127, 81, 16, 98, 204, 33, 255, 26,
+			36, 194, 79, 189, 24, 33, 212, 120, 133, 117, 22, 181, 183, 58,
+			127, 11, 38, 144, 222, 150, 223, 22, 27, 96, 213, 189, 34, 113,
+			140, 96, 39, 52, 104, 3, 168, 9, 48, 102, 0, 168, 167, 240,
+			152, 5, 224, 177, 227, 244, 77, 216, 255, 113, 135, 252, 170, 101,
+			94, 40, 6, 223, 214, 173, 112, 170, 143, 36, 186, 3, 54, 170,
+			117, 183, 180, 170, 206, 154, 213, 25, 69, 246, 10, 57, 53, 199,
+			9, 86, 175, 193, 28, 128, 131, 170, 169, 227, 6, 128, 242, 10,
+			57, 53, 199, 45, 0, 239, 185, 143, 62, 107, 80, 139, 128, 102,
+			246, 235, 150, 121, 176, 248, 212, 223, 250, 14, 249, 183, 223, 11,
+			212, 32, 237, 62, 104, 137, 188, 112, 78, 241, 194, 249, 175, 91,
+			242, 194, 57, 197, 11, 231, 191, 110, 221, 54, 33, 142, 51, 6,
+			28, 242, 27, 150, 57, 34, 10, 14, 244, 1, 52, 40, 94, 201,
+			164, 3, 80, 48, 11, 154, 18, 28, 162, 38, 25, 116, 114, 191,
+			105, 245, 189, 139, 24, 136, 6, 212, 166, 223, 180, 242, 183, 211,
+			223, 177, 41, 33, 131, 102, 159, 67, 62, 111, 153, 175, 42, 254,
+			170, 13, 130, 24, 55, 2, 153, 195, 172, 212, 207, 254, 172, 178,
+			96, 64, 174, 236, 205, 216, 122, 215, 45, 38, 101, 216, 202, 172,
+			58, 152, 67, 31, 83, 204, 128, 206, 232, 38, 222, 150, 135, 209,
+			120, 180, 121, 171, 7, 59, 213, 239, 105, 227, 241, 152, 188, 178,
+			155, 30, 112, 138, 167, 96, 51, 97, 236, 196, 61, 247, 11, 140,
+			45, 39, 147, 49, 243, 121, 28, 83, 198, 235, 117, 175, 234, 225,
+			189, 174, 6, 104, 116, 124, 151, 71, 172, 206, 221, 164, 29, 241,
+			88, 152, 172, 97, 40, 97, 169, 69, 77, 86, 60, 184, 220, 19,
+			186, 79, 251, 207, 43, 131, 46, 191, 233, 98, 144, 189, 174, 227,
+			99, 166, 179, 63, 24, 134, 236, 141, 34, 124, 168, 156, 183, 183,
+			120, 21, 130, 221, 143, 212, 150, 175, 236, 103, 120, 236, 60, 12,
+			64, 211, 189, 137, 95, 158, 238, 246, 49, 229, 153, 211, 126, 208,
+			217, 197, 97, 57, 144, 65, 53, 79, 184, 102, 95, 204, 16, 52,
+			150, 158, 127, 152, 53, 59, 84, 20, 111, 56, 117, 115, 55, 238,
+			74, 209, 243, 73, 245, 27, 54, 181, 184, 65, 85, 58, 240, 69,
+			17, 115, 13, 117, 9, 229, 209, 176, 37, 253, 79, 99, 113, 30,
+			16, 227, 78, 161, 215, 247, 31, 171, 188, 164, 189, 195, 229, 158,
+			64, 29, 223, 9, 161, 38, 60, 78, 146, 93, 60, 3, 78, 34,
+			175, 170, 99, 222, 226, 232, 243, 160, 30, 70, 85, 185, 159, 79,
+			246, 11, 180, 39, 5, 196, 32, 158, 124, 125, 94, 9, 136, 65,
+			60, 249, 250, 188, 210, 177, 7, 209, 116, 248, 121, 171, 48, 169,
+			64, 11, 192, 83, 167, 21, 152, 7, 80, 218, 70, 7, 205, 190,
+			126, 0, 239, 125, 37, 253, 146, 129, 147, 198, 112, 200, 127, 181,
+			204, 7, 139, 255, 217, 96, 139, 226, 156, 72, 104, 48, 25, 203,
+			131, 180, 2, 233, 215, 166, 75, 181, 204, 65, 66, 137, 169, 71,
+			61, 50, 207, 167, 99, 216, 216, 186, 239, 85, 19, 117, 125, 82,
+			88, 148, 21, 38, 229, 167, 161, 110, 111, 161, 20, 114, 49, 24,
+			21, 107, 130, 194, 169, 131, 171, 73, 69, 72, 206, 91, 238, 198,
+			30, 143, 46, 178, 128, 239, 74, 35, 132, 124, 252, 124, 39, 244,
+			20, 187, 200, 51, 149, 76, 35, 75, 154, 142, 6, 193, 222, 106,
+			208, 6, 80, 211, 209, 64, 90, 20, 102, 21, 104, 1, 56, 119,
+			78, 129, 121, 0, 207, 47, 41, 176, 31, 192, 7, 22, 233, 151,
+			5, 29, 77, 135, 124, 197, 50, 207, 22, 127, 47, 221, 214, 42,
+			246, 126, 217, 118, 182, 153, 57, 244, 45, 110, 103, 229, 110, 150,
+			190, 228, 237, 108, 134, 237, 69, 255, 77, 130, 29, 214, 160, 13,
+			160, 38, 37, 176, 213, 87, 212, 142, 118, 16, 207, 125, 190, 98,
+			29, 185, 83, 129, 121, 0, 143, 158, 81, 96, 63, 128, 167, 102,
+			133, 80, 31, 112, 200, 87, 45, 115, 12, 215, 134, 65, 88, 27,
+			190, 106, 13, 14, 137, 156, 184, 54, 100, 65, 83, 130, 34, 47,
+			126, 28, 145, 77, 24, 48, 122, 64, 83, 130, 34, 47, 66, 163,
+			142, 252, 104, 26, 221, 160, 250, 250, 225, 28, 142, 173, 229, 144,
+			239, 39, 230, 157, 197, 119, 230, 64, 147, 209, 55, 116, 212, 248,
+			138, 153, 223, 125, 154, 159, 209, 15, 221, 22, 250, 229, 119, 132,
+			40, 146, 227, 70, 33, 93, 93, 227, 82, 215, 149, 116, 138, 16,
+			195, 12, 82, 94, 249, 106, 222, 217, 232, 180, 120, 153, 97, 216,
+			124, 248, 249, 42, 72, 223, 20, 51, 234, 126, 118, 246, 34, 77,
+			149, 150, 90, 246, 138, 149, 31, 134, 55, 98, 12, 205, 161, 208,
+			201, 6, 95, 113, 91, 232, 220, 137, 143, 227, 40, 9, 159, 149,
+			242, 234, 33, 157, 110, 185, 158, 230, 112, 125, 38, 155, 197, 110,
+			240, 142, 108, 196, 158, 44, 186, 193, 114, 99, 118, 63, 155, 147,
+			217, 158, 22, 127, 180, 80, 237, 110, 80, 79, 239, 40, 91, 238,
+			137, 122, 129, 206, 116, 141, 48, 140, 133, 32, 205, 152, 41, 196,
+			184, 168, 230, 223, 143, 42, 128, 158, 33, 91, 237, 4, 53, 106,
+			230, 178, 64, 68, 48, 129, 177, 241, 186, 166, 161, 182, 227, 38,
+			33, 107, 128, 198, 0, 223, 110, 240, 142, 184, 248, 46, 29, 241,
+			5, 193, 51, 7, 107, 243, 215, 150, 81, 187, 194, 187, 14, 123,
+			98, 116, 224, 49, 154, 114, 186, 193, 216, 171, 110, 76, 153, 87,
+			79, 239, 98, 138, 25, 184, 255, 253, 50, 188, 154, 177, 186, 177,
+			116, 65, 69, 152, 148, 198, 78, 173, 74, 247, 68, 216, 101, 243,
+			194, 5, 65, 169, 61, 200, 85, 34, 34, 31, 85, 123, 99, 113,
+			77, 85, 34, 144, 50, 84, 57, 135, 121, 205, 46, 179, 170, 176,
+			126, 203, 29, 138, 90, 152, 164, 55, 91, 186, 64, 89, 4, 167,
+			136, 6, 109, 0, 181, 52, 176, 12, 0, 11, 7, 21, 136, 243,
+			233, 240, 17, 122, 66, 206, 247, 31, 32, 230, 112, 233, 14, 60,
+			3, 247, 189, 4, 180, 11, 121, 70, 182, 229, 115, 42, 39, 172,
+			5, 217, 244, 204, 7, 140, 89, 208, 148, 224, 81, 137, 241, 109,
+			128, 209, 65, 140, 129, 27, 132, 155, 110, 188, 9, 152, 21, 50,
+			2, 57, 116, 105, 98, 116, 131, 166, 4, 87, 113, 242, 19, 135,
+			188, 131, 124, 231, 2, 70, 13, 138, 203, 9, 41, 173, 72, 14,
+			192, 65, 37, 27, 241, 114, 2, 57, 170, 86, 29, 188, 156, 64,
+			100, 192, 168, 65, 219, 33, 63, 76, 94, 158, 128, 81, 131, 160,
+			191, 255, 48, 145, 250, 251, 32, 234, 239, 63, 76, 164, 254, 62,
+			136, 250, 251, 15, 147, 219, 38, 80, 13, 31, 114, 114, 239, 33,
+			125, 127, 44, 213, 240, 33, 195, 33, 239, 33, 249, 113, 250, 99,
+			38, 37, 100, 8, 212, 240, 247, 17, 179, 82, 124, 151, 137, 20,
+			195, 167, 170, 82, 102, 85, 103, 108, 232, 192, 117, 250, 116, 239,
+			185, 185, 212, 215, 221, 212, 129, 150, 222, 226, 194, 189, 116, 217,
+			108, 184, 1, 144, 95, 251, 19, 237, 2, 51, 87, 152, 222, 203,
+			168, 99, 10, 170, 9, 179, 197, 253, 112, 87, 105, 30, 221, 251,
+			209, 14, 79, 210, 233, 155, 58, 60, 132, 45, 174, 222, 69, 128,
+			214, 192, 218, 201, 217, 204, 12, 139, 195, 40, 234, 148, 217, 46,
+			159, 244, 125, 134, 18, 62, 20, 23, 159, 106, 28, 47, 71, 162,
+			163, 107, 27, 84, 116, 117, 82, 115, 76, 140, 250, 16, 106, 116,
+			239, 83, 76, 48, 132, 81, 195, 222, 71, 228, 109, 142, 33, 212,
+			232, 222, 71, 100, 28, 143, 33, 212, 232, 222, 71, 14, 22, 21,
+			152, 7, 240, 208, 140, 2, 251, 1, 188, 171, 140, 65, 89, 134,
+			72, 159, 147, 123, 63, 49, 127, 140, 136, 160, 44, 67, 120, 94,
+			253, 126, 210, 15, 115, 35, 7, 32, 140, 207, 7, 9, 57, 80,
+			60, 160, 237, 20, 77, 140, 78, 138, 103, 180, 67, 242, 40, 250,
+			131, 132, 100, 18, 76, 72, 24, 30, 65, 23, 137, 33, 113, 20,
+			253, 97, 34, 15, 144, 135, 228, 137, 242, 135, 9, 201, 167, 9,
+			38, 36, 12, 14, 233, 18, 166, 67, 62, 66, 100, 24, 158, 33,
+			121, 48, 252, 17, 34, 15, 134, 135, 228, 193, 240, 71, 200, 216,
+			56, 253, 79, 130, 139, 12, 135, 60, 71, 204, 67, 197, 79, 155,
+			114, 222, 225, 253, 103, 57, 92, 242, 204, 94, 122, 5, 137, 139,
+			175, 74, 120, 182, 34, 175, 137, 207, 224, 40, 125, 16, 29, 76,
+			81, 148, 48, 87, 236, 144, 244, 102, 106, 15, 107, 137, 241, 134,
+			157, 77, 133, 173, 185, 114, 165, 119, 3, 141, 29, 246, 30, 187,
+			145, 167, 188, 19, 49, 82, 161, 138, 136, 146, 122, 22, 113, 193,
+			64, 229, 236, 149, 54, 55, 138, 220, 14, 154, 68, 68, 244, 45,
+			92, 3, 180, 243, 173, 223, 27, 51, 105, 203, 15, 183, 42, 108,
+			89, 221, 52, 47, 11, 241, 172, 142, 188, 64, 50, 39, 34, 158,
+			57, 94, 38, 199, 83, 52, 233, 128, 134, 170, 176, 60, 190, 19,
+			68, 203, 68, 246, 17, 28, 131, 23, 50, 82, 230, 195, 11, 25,
+			74, 90, 15, 137, 11, 25, 164, 160, 152, 15, 47, 100, 0, 243,
+			253, 35, 27, 7, 198, 116, 200, 207, 16, 243, 92, 241, 7, 108,
+			28, 24, 241, 82, 156, 246, 206, 145, 134, 26, 158, 250, 36, 174,
+			163, 58, 34, 40, 164, 205, 89, 210, 87, 60, 148, 81, 16, 228,
+			109, 246, 236, 170, 4, 187, 110, 253, 154, 8, 118, 30, 202, 221,
+			115, 158, 109, 225, 204, 74, 248, 118, 228, 250, 72, 251, 186, 119,
+			83, 133, 75, 161, 108, 202, 11, 146, 123, 206, 151, 89, 91, 254,
+			141, 229, 95, 204, 132, 9, 242, 215, 116, 133, 177, 249, 76, 136,
+			59, 213, 17, 253, 220, 27, 21, 17, 139, 36, 127, 224, 128, 101,
+			251, 35, 188, 109, 212, 158, 7, 169, 30, 51, 63, 20, 17, 3,
+			64, 111, 246, 208, 147, 70, 56, 253, 0, 191, 54, 220, 22, 136,
+			145, 93, 17, 143, 192, 7, 117, 35, 13, 20, 33, 35, 48, 72,
+			171, 48, 171, 251, 161, 80, 187, 133, 67, 121, 90, 109, 133, 178,
+			117, 20, 104, 29, 248, 170, 223, 162, 211, 187, 1, 217, 9, 220,
+			150, 118, 237, 227, 120, 45, 219, 120, 121, 119, 129, 106, 37, 33,
+			243, 173, 36, 92, 79, 75, 42, 38, 14, 58, 39, 240, 134, 187,
+			227, 133, 81, 230, 94, 5, 10, 14, 49, 86, 148, 233, 39, 243,
+			240, 234, 103, 151, 254, 163, 163, 105, 39, 194, 24, 209, 37, 115,
+			245, 29, 224, 80, 14, 118, 246, 212, 92, 56, 212, 9, 183, 90,
+			183, 134, 54, 127, 225, 14, 189, 29, 134, 219, 149, 166, 155, 52,
+			42, 203, 192, 7, 90, 13, 25, 194, 77, 201, 207, 164, 140, 109,
+			230, 0, 148, 38, 192, 33, 148, 57, 63, 67, 156, 9, 5, 90,
+			0, 30, 58, 172, 192, 60, 128, 71, 230, 20, 216, 15, 224, 212,
+			89, 41, 85, 13, 39, 247, 179, 196, 252, 87, 90, 170, 194, 36,
+			249, 89, 210, 63, 140, 65, 181, 134, 68, 168, 171, 159, 39, 196,
+			41, 222, 33, 13, 168, 153, 83, 110, 113, 23, 74, 8, 58, 17,
+			231, 234, 231, 83, 225, 41, 226, 92, 253, 60, 41, 140, 210, 105,
+			137, 202, 112, 200, 191, 4, 84, 7, 17, 213, 30, 158, 139, 51,
+			200, 12, 145, 55, 69, 6, 130, 247, 95, 102, 145, 153, 14, 249,
+			212, 190, 200, 82, 15, 99, 85, 22, 42, 254, 84, 22, 153, 40,
+			92, 24, 165, 255, 239, 16, 78, 125, 203, 33, 191, 75, 204, 211,
+			197, 223, 31, 82, 62, 26, 153, 203, 19, 91, 122, 11, 226, 187,
+			79, 121, 126, 231, 1, 198, 86, 220, 167, 58, 234, 72, 81, 159,
+			40, 74, 13, 100, 6, 200, 162, 66, 185, 138, 107, 1, 77, 238,
+			6, 50, 246, 197, 174, 114, 174, 19, 126, 165, 153, 157, 22, 222,
+			231, 193, 149, 94, 212, 86, 22, 18, 196, 195, 40, 65, 50, 223,
+			100, 156, 70, 207, 65, 161, 40, 175, 147, 202, 246, 109, 181, 19,
+			165, 8, 11, 69, 78, 120, 147, 8, 185, 44, 172, 117, 146, 239,
+			187, 176, 74, 249, 90, 77, 132, 71, 124, 138, 15, 27, 43, 67,
+			67, 97, 20, 39, 97, 17, 81, 230, 55, 233, 35, 13, 221, 119,
+			241, 238, 15, 244, 182, 103, 143, 224, 70, 156, 213, 35, 206, 133,
+			181, 29, 119, 54, 58, 22, 4, 106, 68, 148, 113, 119, 155, 71,
+			176, 203, 247, 129, 170, 234, 106, 78, 119, 164, 18, 125, 13, 71,
+			235, 123, 234, 206, 140, 118, 244, 163, 122, 237, 232, 50, 242, 195,
+			70, 41, 110, 111, 111, 243, 88, 133, 31, 233, 178, 72, 185, 248,
+			6, 9, 168, 78, 30, 23, 65, 123, 92, 220, 75, 1, 158, 174,
+			246, 116, 69, 171, 193, 72, 158, 97, 36, 205, 162, 153, 169, 189,
+			21, 134, 55, 110, 112, 46, 226, 99, 133, 59, 60, 106, 192, 88,
+			36, 157, 150, 220, 61, 203, 120, 229, 93, 222, 108, 222, 30, 1,
+			162, 92, 65, 153, 43, 156, 13, 147, 204, 11, 3, 65, 194, 163,
+			186, 60, 175, 113, 131, 174, 115, 138, 176, 6, 27, 90, 215, 247,
+			149, 15, 44, 134, 98, 65, 67, 42, 139, 120, 211, 205, 220, 133,
+			172, 48, 246, 96, 59, 130, 97, 0, 189, 1, 88, 45, 226, 110,
+			109, 38, 118, 235, 92, 135, 95, 167, 153, 202, 188, 108, 123, 50,
+			143, 42, 136, 6, 95, 68, 191, 155, 68, 121, 228, 169, 202, 0,
+			27, 10, 99, 232, 187, 48, 119, 233, 107, 19, 162, 66, 100, 231,
+			106, 59, 18, 247, 201, 112, 205, 241, 69, 72, 146, 110, 132, 192,
+			244, 94, 208, 230, 84, 220, 59, 193, 152, 28, 140, 171, 232, 194,
+			146, 45, 43, 180, 235, 174, 127, 239, 110, 117, 239, 222, 26, 159,
+			240, 214, 33, 62, 148, 86, 37, 174, 15, 81, 92, 202, 158, 234,
+			116, 95, 208, 16, 94, 241, 94, 92, 198, 46, 1, 91, 44, 199,
+			203, 98, 222, 122, 79, 241, 218, 212, 180, 82, 180, 186, 102, 55,
+			197, 186, 35, 158, 180, 35, 201, 144, 24, 108, 68, 238, 147, 187,
+			167, 98, 195, 141, 25, 190, 17, 133, 83, 160, 171, 101, 25, 179,
+			125, 192, 161, 195, 110, 212, 209, 151, 17, 66, 229, 232, 182, 15,
+			78, 220, 57, 200, 203, 100, 161, 240, 241, 19, 243, 219, 11, 68,
+			192, 25, 185, 76, 225, 19, 96, 24, 157, 10, 8, 83, 6, 57,
+			207, 93, 173, 17, 182, 218, 81, 43, 20, 222, 23, 64, 24, 170,
+			102, 6, 168, 27, 65, 239, 218, 40, 13, 166, 72, 238, 248, 5,
+			233, 77, 181, 13, 91, 71, 111, 73, 228, 123, 3, 94, 146, 165,
+			184, 58, 74, 200, 120, 99, 101, 198, 70, 73, 203, 238, 102, 32,
+			106, 249, 58, 216, 41, 113, 131, 225, 148, 108, 134, 135, 15, 205,
+			117, 53, 5, 77, 141, 167, 208, 97, 249, 20, 125, 161, 108, 221,
+			178, 73, 201, 51, 17, 193, 166, 203, 128, 214, 0, 93, 120, 139,
+			243, 64, 82, 92, 175, 231, 22, 193, 21, 71, 131, 54, 128, 90,
+			81, 181, 12, 0, 101, 72, 192, 33, 52, 43, 252, 46, 185, 67,
+			45, 239, 86, 30, 192, 131, 167, 20, 216, 15, 224, 137, 105, 250,
+			69, 3, 215, 50, 226, 144, 63, 36, 230, 217, 226, 127, 204, 186,
+			33, 129, 204, 122, 217, 172, 181, 202, 78, 30, 127, 123, 182, 90,
+			25, 3, 240, 37, 185, 30, 201, 232, 173, 162, 231, 68, 116, 85,
+			131, 54, 128, 154, 136, 196, 0, 176, 160, 246, 150, 196, 2, 80,
+			90, 106, 135, 208, 177, 250, 15, 137, 180, 212, 14, 161, 99, 245,
+			31, 146, 83, 179, 244, 1, 164, 161, 237, 144, 63, 34, 102, 185,
+			120, 246, 91, 127, 32, 69, 224, 179, 9, 98, 208, 32, 34, 212,
+			77, 179, 13, 0, 245, 248, 218, 22, 128, 119, 28, 84, 96, 30,
+			192, 226, 105, 5, 246, 3, 120, 242, 20, 90, 109, 134, 64, 243,
+			251, 210, 119, 208, 106, 51, 132, 206, 173, 95, 74, 155, 154, 195,
+			10, 6, 21, 157, 114, 6, 128, 210, 106, 51, 132, 206, 173, 95,
+			82, 86, 155, 33, 219, 33, 95, 126, 153, 172, 54, 67, 118, 31,
+			32, 151, 86, 155, 33, 180, 218, 124, 89, 89, 109, 134, 208, 106,
+			243, 101, 114, 219, 4, 61, 12, 237, 24, 112, 200, 87, 136, 57,
+			92, 58, 32, 31, 30, 169, 177, 39, 81, 9, 19, 152, 6, 250,
+			224, 179, 180, 130, 13, 161, 169, 61, 11, 154, 18, 28, 162, 38,
+			25, 118, 114, 95, 35, 125, 127, 33, 237, 63, 195, 134, 67, 190,
+			70, 242, 227, 72, 249, 97, 80, 132, 255, 244, 59, 72, 249, 97,
+			52, 149, 252, 169, 162, 252, 48, 154, 74, 254, 84, 81, 126, 24,
+			213, 233, 63, 85, 148, 31, 70, 83, 201, 159, 42, 202, 15, 219,
+			14, 249, 250, 203, 68, 249, 97, 160, 252, 215, 21, 229, 135, 145,
+			242, 95, 87, 148, 31, 70, 202, 127, 93, 217, 203, 70, 156, 220,
+			95, 146, 190, 31, 180, 5, 189, 70, 12, 135, 252, 37, 201, 143,
+			209, 39, 40, 33, 35, 64, 175, 191, 33, 38, 43, 174, 137, 67,
+			235, 110, 207, 11, 117, 134, 141, 175, 119, 179, 166, 120, 165, 43,
+			181, 141, 137, 151, 18, 240, 69, 167, 36, 189, 214, 65, 85, 48,
+			74, 108, 203, 8, 18, 240, 111, 20, 1, 71, 240, 244, 240, 111,
+			212, 44, 27, 65, 2, 254, 13, 41, 28, 82, 160, 5, 224, 157,
+			71, 233, 231, 13, 108, 158, 225, 144, 183, 216, 230, 217, 226, 111,
+			167, 114, 82, 134, 144, 121, 25, 15, 181, 196, 61, 172, 151, 87,
+			74, 226, 217, 174, 166, 145, 65, 176, 159, 26, 180, 1, 212, 52,
+			50, 144, 10, 82, 72, 142, 160, 73, 228, 45, 182, 20, 146, 35,
+			120, 50, 248, 22, 91, 10, 201, 17, 60, 25, 124, 139, 125, 106,
+			22, 141, 209, 35, 3, 14, 121, 214, 126, 1, 99, 244, 8, 204,
+			187, 103, 109, 57, 209, 70, 112, 222, 101, 65, 83, 130, 171, 56,
+			26, 166, 67, 222, 106, 127, 231, 38, 215, 8, 238, 152, 223, 154,
+			246, 27, 228, 230, 91, 237, 65, 213, 51, 24, 253, 183, 218, 114,
+			114, 141, 224, 142, 249, 173, 182, 156, 92, 35, 182, 67, 222, 110,
+			191, 60, 147, 107, 4, 38, 215, 219, 109, 57, 185, 70, 112, 114,
+			189, 221, 150, 147, 107, 4, 39, 215, 219, 109, 57, 185, 14, 56,
+			185, 119, 216, 125, 239, 151, 147, 235, 128, 225, 144, 119, 216, 249,
+			9, 250, 231, 192, 190, 7, 48, 26, 15, 176, 239, 23, 123, 216,
+			87, 236, 146, 94, 118, 38, 22, 245, 188, 220, 135, 179, 153, 88,
+			79, 114, 96, 15, 136, 192, 67, 106, 96, 15, 136, 192, 67, 138,
+			161, 15, 136, 192, 67, 138, 161, 15, 136, 192, 67, 138, 161, 15,
+			160, 129, 249, 221, 138, 161, 15, 160, 129, 249, 221, 192, 208, 171,
+			72, 82, 195, 33, 239, 253, 14, 242, 224, 1, 156, 123, 239, 77,
+			155, 106, 228, 0, 28, 84, 141, 49, 176, 62, 201, 131, 7, 112,
+			238, 189, 87, 241, 224, 1, 219, 33, 239, 123, 153, 120, 240, 0,
+			240, 224, 251, 20, 15, 30, 64, 30, 124, 159, 226, 193, 3, 200,
+			131, 239, 83, 60, 88, 112, 114, 31, 180, 251, 254, 185, 228, 193,
+			130, 225, 144, 15, 218, 249, 219, 233, 127, 180, 40, 33, 5, 224,
+			193, 231, 108, 115, 46, 235, 26, 144, 94, 47, 125, 25, 25, 80,
+			86, 242, 114, 115, 159, 186, 152, 81, 161, 115, 95, 52, 112, 103,
+			121, 129, 137, 224, 65, 58, 24, 196, 89, 29, 5, 226, 220, 156,
+			10, 60, 148, 70, 130, 215, 47, 98, 107, 213, 113, 237, 218, 2,
+			101, 140, 213, 35, 183, 201, 119, 195, 232, 70, 133, 177, 71, 56,
+			115, 91, 161, 31, 110, 3, 39, 225, 195, 32, 161, 27, 213, 228,
+			174, 43, 206, 188, 96, 17, 178, 176, 29, 197, 220, 223, 225, 177,
+			60, 242, 101, 108, 151, 139, 123, 54, 42, 130, 163, 48, 92, 224,
+			141, 17, 140, 205, 186, 133, 247, 83, 32, 91, 141, 87, 61, 105,
+			122, 80, 7, 61, 234, 9, 85, 64, 36, 95, 81, 149, 172, 91,
+			192, 89, 246, 156, 98, 221, 2, 206, 178, 231, 212, 44, 43, 224,
+			44, 123, 78, 205, 178, 2, 206, 178, 231, 108, 233, 215, 95, 192,
+			89, 246, 156, 45, 253, 250, 11, 56, 203, 158, 179, 79, 159, 193,
+			89, 86, 128, 89, 246, 241, 239, 224, 44, 43, 224, 44, 251, 120,
+			218, 84, 152, 101, 31, 87, 179, 172, 128, 179, 236, 227, 106, 150,
+			21, 112, 150, 125, 92, 205, 178, 130, 237, 144, 231, 95, 166, 89,
+			86, 128, 89, 246, 188, 154, 101, 5, 156, 101, 207, 171, 89, 86,
+			192, 89, 246, 188, 154, 101, 163, 78, 238, 19, 118, 223, 111, 200,
+			89, 54, 106, 56, 228, 19, 118, 254, 54, 250, 219, 48, 203, 70,
+			97, 150, 125, 18, 102, 217, 127, 206, 58, 224, 160, 125, 231, 101,
+			246, 191, 129, 58, 94, 126, 247, 27, 121, 137, 243, 255, 111, 83,
+			108, 20, 167, 216, 39, 21, 223, 142, 226, 20, 251, 164, 154, 98,
+			163, 56, 197, 62, 169, 166, 216, 40, 78, 177, 79, 170, 41, 54,
+			138, 83, 236, 147, 106, 138, 141, 226, 20, 251, 36, 76, 177, 175,
+			128, 114, 48, 74, 250, 156, 220, 47, 218, 230, 191, 177, 173, 110,
+			175, 45, 105, 20, 172, 241, 25, 113, 139, 121, 6, 45, 179, 83,
+			97, 36, 76, 117, 94, 192, 30, 218, 216, 184, 6, 115, 210, 119,
+			131, 42, 159, 22, 131, 95, 227, 205, 86, 152, 240, 0, 134, 53,
+			140, 88, 32, 236, 41, 15, 136, 188, 248, 18, 47, 94, 139, 235,
+			53, 189, 164, 230, 182, 203, 75, 27, 192, 28, 91, 226, 2, 178,
+			91, 23, 47, 227, 195, 176, 11, 255, 206, 107, 215, 51, 223, 211,
+			234, 180, 245, 79, 153, 180, 123, 206, 104, 174, 173, 174, 111, 40,
+			98, 226, 97, 240, 47, 218, 253, 119, 224, 185, 236, 168, 56, 12,
+			254, 37, 155, 28, 70, 147, 255, 168, 60, 251, 253, 37, 91, 62,
+			153, 49, 42, 207, 126, 127, 201, 46, 30, 162, 39, 100, 9, 195,
+			33, 255, 218, 38, 19, 165, 113, 225, 41, 194, 227, 76, 91, 168,
+			46, 102, 136, 108, 99, 105, 130, 9, 9, 183, 223, 65, 239, 149,
+			120, 76, 135, 252, 178, 77, 198, 74, 147, 89, 210, 137, 160, 114,
+			42, 70, 23, 70, 77, 16, 99, 16, 167, 168, 161, 5, 191, 108,
+			203, 55, 95, 70, 229, 209, 241, 47, 219, 248, 180, 16, 8, 2,
+			195, 201, 253, 138, 109, 254, 91, 251, 46, 57, 234, 32, 248, 126,
+			37, 101, 32, 16, 124, 191, 98, 203, 112, 135, 163, 216, 208, 95,
+			177, 15, 207, 40, 208, 2, 80, 6, 7, 24, 69, 213, 254, 223,
+			218, 185, 147, 10, 236, 7, 112, 244, 4, 202, 232, 81, 168, 247,
+			211, 223, 65, 25, 61, 138, 218, 248, 167, 211, 166, 130, 54, 254,
+			105, 37, 163, 71, 177, 227, 159, 86, 50, 122, 20, 181, 241, 79,
+			43, 25, 61, 106, 59, 228, 51, 47, 147, 140, 30, 5, 25, 253,
+			25, 37, 163, 71, 81, 70, 127, 70, 201, 232, 81, 148, 209, 159,
+			1, 25, 253, 3, 22, 53, 137, 227, 228, 126, 199, 238, 251, 127,
+			108, 163, 248, 77, 147, 205, 107, 19, 159, 62, 72, 5, 169, 224,
+			234, 29, 110, 74, 54, 109, 204, 215, 84, 146, 87, 210, 132, 195,
+			175, 219, 106, 113, 87, 220, 183, 85, 189, 144, 33, 182, 68, 176,
+			103, 117, 77, 87, 187, 40, 93, 184, 112, 77, 6, 42, 19, 49,
+			73, 178, 225, 107, 195, 208, 87, 161, 15, 99, 41, 219, 240, 12,
+			7, 99, 123, 65, 3, 23, 51, 113, 202, 241, 242, 88, 92, 233,
+			186, 198, 218, 211, 4, 47, 232, 138, 108, 46, 74, 200, 23, 200,
+			133, 61, 91, 180, 47, 69, 123, 225, 130, 68, 49, 53, 45, 196,
+			69, 43, 10, 69, 144, 254, 158, 108, 11, 97, 171, 179, 17, 78,
+			77, 79, 203, 131, 44, 12, 19, 129, 147, 227, 122, 54, 54, 154,
+			14, 160, 166, 162, 175, 137, 184, 65, 142, 225, 144, 223, 177, 243,
+			135, 232, 167, 77, 74, 136, 99, 245, 57, 185, 223, 181, 205, 255,
+			219, 182, 138, 255, 66, 184, 85, 100, 111, 121, 119, 5, 91, 75,
+			15, 142, 48, 158, 158, 140, 45, 161, 71, 81, 4, 253, 220, 150,
+			55, 204, 41, 115, 89, 45, 76, 102, 84, 188, 149, 154, 114, 238,
+			245, 226, 205, 52, 168, 132, 140, 145, 207, 188, 122, 61, 83, 58,
+			139, 50, 200, 68, 92, 99, 83, 53, 30, 132, 137, 10, 27, 33,
+			30, 53, 129, 161, 234, 226, 129, 184, 197, 171, 113, 175, 11, 220,
+			116, 133, 178, 165, 202, 118, 165, 252, 70, 246, 58, 249, 154, 54,
+			58, 72, 188, 190, 204, 94, 87, 218, 114, 163, 202, 150, 251, 84,
+			169, 140, 141, 193, 164, 55, 180, 111, 234, 44, 236, 233, 76, 139,
+			168, 120, 133, 123, 74, 150, 153, 174, 64, 78, 57, 89, 29, 12,
+			71, 252, 187, 54, 21, 207, 64, 57, 34, 28, 241, 239, 217, 164,
+			132, 114, 201, 145, 1, 136, 127, 207, 150, 225, 129, 29, 25, 128,
+			248, 247, 236, 225, 177, 52, 193, 128, 132, 241, 35, 105, 130, 5,
+			9, 236, 152, 198, 105, 56, 228, 247, 109, 114, 92, 103, 0, 97,
+			246, 251, 89, 156, 134, 13, 9, 195, 163, 105, 2, 22, 113, 238,
+			76, 19, 44, 72, 56, 86, 194, 185, 236, 64, 43, 63, 103, 155,
+			34, 72, 166, 131, 109, 252, 156, 146, 56, 14, 26, 215, 62, 103,
+			15, 142, 41, 208, 0, 112, 124, 66, 129, 22, 128, 135, 14, 139,
+			247, 132, 28, 104, 220, 23, 108, 115, 178, 248, 118, 35, 243, 236,
+			197, 11, 112, 83, 25, 70, 106, 183, 225, 38, 200, 197, 232, 169,
+			128, 26, 88, 120, 131, 195, 132, 143, 40, 44, 5, 34, 94, 37,
+			70, 13, 116, 99, 245, 74, 189, 62, 68, 89, 146, 55, 22, 228,
+			11, 67, 98, 242, 74, 15, 70, 249, 96, 145, 234, 11, 144, 234,
+			11, 105, 215, 128, 80, 95, 176, 165, 51, 128, 131, 100, 250, 130,
+			45, 175, 197, 57, 72, 164, 47, 216, 39, 239, 146, 68, 50, 29,
+			242, 71, 182, 57, 45, 63, 130, 88, 254, 163, 20, 19, 218, 189,
+			83, 76, 64, 134, 63, 178, 157, 19, 10, 180, 0, 156, 156, 146,
+			152, 44, 135, 124, 209, 150, 161, 119, 29, 60, 208, 248, 98, 138,
+			201, 178, 1, 148, 110, 95, 14, 30, 104, 124, 209, 30, 61, 174,
+			64, 44, 123, 215, 164, 196, 68, 28, 242, 95, 108, 83, 125, 36,
+			2, 84, 152, 136, 13, 160, 110, 19, 49, 0, 148, 183, 157, 28,
+			180, 234, 255, 151, 148, 5, 108, 135, 252, 87, 219, 84, 93, 183,
+			9, 130, 10, 147, 141, 95, 117, 155, 108, 3, 192, 81, 197, 47,
+			182, 5, 160, 12, 170, 234, 160, 153, 221, 150, 113, 116, 29, 97,
+			35, 79, 49, 229, 108, 0, 117, 155, 208, 70, 110, 59, 199, 20,
+			104, 1, 120, 226, 36, 253, 79, 6, 53, 201, 152, 147, 251, 154,
+			221, 247, 39, 57, 163, 248, 36, 91, 10, 170, 110, 43, 150, 193,
+			48, 95, 218, 251, 218, 34, 178, 83, 20, 54, 149, 63, 14, 101,
+			15, 122, 62, 239, 137, 94, 203, 118, 221, 76, 112, 143, 10, 157,
+			123, 226, 59, 25, 126, 180, 251, 181, 111, 108, 184, 144, 194, 99,
+			134, 67, 190, 102, 231, 111, 167, 223, 59, 74, 9, 25, 131, 25,
+			248, 161, 156, 121, 172, 248, 181, 2, 155, 103, 43, 161, 140, 185,
+			232, 165, 161, 90, 93, 214, 242, 184, 56, 187, 238, 198, 200, 220,
+			238, 232, 92, 208, 87, 202, 170, 97, 20, 241, 184, 21, 6, 194,
+			217, 205, 205, 158, 253, 165, 17, 80, 245, 93, 139, 238, 167, 193,
+			69, 208, 63, 249, 116, 67, 250, 150, 68, 18, 178, 229, 197, 37,
+			124, 87, 168, 38, 223, 231, 225, 81, 92, 238, 185, 26, 150, 94,
+			22, 150, 81, 179, 188, 166, 231, 187, 17, 213, 143, 138, 203, 135,
+			161, 48, 250, 94, 153, 197, 110, 7, 54, 0, 226, 158, 143, 232,
+			130, 246, 78, 191, 229, 157, 35, 32, 171, 140, 108, 20, 134, 218,
+			213, 252, 105, 202, 86, 56, 94, 143, 10, 195, 27, 204, 77, 68,
+			188, 214, 212, 47, 52, 237, 55, 98, 127, 33, 84, 143, 75, 191,
+			245, 199, 31, 215, 127, 224, 191, 199, 31, 135, 143, 174, 252, 184,
+			85, 197, 63, 53, 206, 88, 157, 177, 237, 134, 71, 97, 187, 164,
+			35, 143, 234, 136, 46, 204, 151, 227, 41, 92, 248, 227, 150, 27,
+			48, 134, 209, 91, 88, 247, 191, 236, 42, 195, 216, 235, 220, 178,
+			55, 205, 216, 235, 216, 249, 50, 59, 83, 102, 115, 101, 118, 134,
+			189, 30, 243, 129, 96, 221, 109, 132, 254, 222, 142, 85, 100, 193,
+			173, 158, 130, 101, 118, 30, 202, 66, 65, 223, 221, 226, 62, 155,
+			82, 189, 159, 22, 69, 170, 229, 218, 158, 34, 119, 171, 34, 226,
+			73, 50, 65, 38, 153, 159, 151, 235, 123, 242, 159, 85, 249, 69,
+			156, 202, 122, 24, 202, 204, 219, 229, 198, 158, 204, 231, 116, 102,
+			17, 226, 113, 234, 236, 180, 122, 112, 0, 200, 52, 195, 230, 53,
+			217, 164, 15, 128, 14, 40, 173, 221, 58, 165, 67, 73, 18, 115,
+			191, 46, 159, 76, 147, 231, 229, 24, 183, 140, 101, 153, 94, 60,
+			39, 37, 3, 156, 122, 201, 116, 230, 154, 81, 91, 57, 236, 136,
+			120, 91, 232, 226, 30, 214, 149, 199, 102, 44, 98, 255, 50, 6,
+			155, 96, 225, 138, 194, 131, 170, 31, 74, 47, 0, 237, 174, 41,
+			238, 30, 9, 13, 166, 194, 186, 153, 28, 125, 218, 18, 47, 74,
+			35, 100, 162, 55, 103, 245, 6, 155, 106, 133, 113, 236, 109, 249,
+			157, 236, 187, 86, 218, 213, 35, 213, 124, 50, 81, 139, 133, 218,
+			135, 81, 43, 197, 229, 56, 233, 50, 161, 201, 181, 219, 128, 173,
+			35, 242, 23, 82, 77, 31, 4, 149, 82, 13, 191, 164, 169, 136,
+			59, 126, 237, 242, 136, 81, 106, 2, 65, 173, 10, 12, 195, 21,
+			213, 22, 205, 196, 233, 94, 76, 223, 219, 130, 186, 20, 65, 133,
+			79, 99, 172, 156, 26, 5, 117, 50, 244, 203, 62, 52, 128, 239,
+			97, 181, 34, 220, 161, 66, 197, 34, 64, 175, 238, 62, 198, 83,
+			146, 209, 201, 89, 51, 140, 209, 168, 16, 110, 237, 120, 97, 59,
+			86, 196, 85, 47, 202, 137, 190, 213, 74, 146, 174, 238, 182, 235,
+			5, 58, 204, 169, 138, 140, 155, 13, 234, 154, 29, 134, 238, 231,
+			16, 226, 106, 216, 226, 101, 225, 182, 139, 78, 10, 58, 108, 236,
+			62, 189, 238, 102, 213, 201, 88, 76, 111, 229, 244, 35, 174, 93,
+			96, 172, 82, 201, 85, 94, 18, 75, 197, 85, 230, 21, 188, 34,
+			90, 36, 217, 37, 211, 31, 206, 75, 194, 93, 169, 139, 23, 82,
+			2, 170, 61, 144, 112, 206, 2, 44, 91, 124, 219, 11, 144, 141,
+			164, 214, 213, 75, 25, 113, 45, 52, 110, 184, 145, 216, 90, 244,
+			132, 29, 86, 78, 60, 34, 92, 42, 150, 193, 78, 62, 44, 124,
+			77, 132, 207, 140, 187, 95, 143, 179, 221, 140, 195, 166, 138, 38,
+			217, 147, 19, 48, 235, 141, 93, 147, 187, 250, 141, 16, 129, 2,
+			54, 77, 60, 168, 185, 251, 76, 34, 86, 194, 183, 105, 75, 114,
+			7, 139, 66, 18, 31, 161, 116, 133, 132, 130, 158, 101, 162, 72,
+			235, 153, 217, 245, 178, 69, 26, 159, 49, 229, 104, 64, 148, 46,
+			152, 158, 120, 152, 75, 197, 135, 144, 254, 196, 128, 84, 6, 54,
+			243, 34, 220, 81, 134, 65, 234, 76, 168, 159, 197, 96, 51, 108,
+			33, 13, 89, 36, 94, 94, 193, 27, 0, 82, 239, 205, 76, 35,
+			169, 161, 182, 162, 112, 203, 221, 18, 206, 131, 53, 30, 123, 219,
+			1, 218, 193, 48, 238, 48, 154, 9, 89, 130, 243, 89, 81, 73,
+			89, 14, 68, 32, 141, 196, 13, 106, 101, 80, 138, 209, 137, 93,
+			184, 199, 134, 245, 76, 45, 85, 17, 24, 137, 137, 135, 49, 170,
+			97, 84, 203, 4, 109, 196, 123, 8, 82, 57, 30, 67, 189, 255,
+			67, 57, 83, 131, 57, 0, 165, 222, 63, 134, 122, 255, 135, 114,
+			227, 135, 21, 104, 1, 120, 148, 161, 129, 101, 12, 118, 145, 31,
+			206, 153, 95, 203, 9, 95, 216, 49, 220, 8, 125, 56, 71, 29,
+			250, 230, 126, 154, 3, 24, 52, 156, 159, 203, 145, 114, 241, 171,
+			185, 108, 4, 122, 25, 35, 219, 141, 18, 197, 174, 183, 210, 209,
+			212, 5, 96, 249, 22, 13, 213, 125, 196, 240, 216, 25, 23, 123,
+			105, 59, 205, 88, 66, 229, 211, 80, 66, 168, 201, 120, 94, 232,
+			177, 229, 10, 41, 9, 74, 162, 184, 110, 27, 133, 97, 178, 111,
+			11, 84, 200, 21, 144, 72, 50, 50, 92, 210, 21, 76, 94, 77,
+			227, 204, 36, 246, 98, 68, 143, 139, 62, 46, 126, 231, 112, 241,
+			187, 23, 87, 74, 154, 202, 230, 11, 66, 4, 251, 188, 162, 174,
+			82, 195, 56, 78, 157, 155, 102, 108, 118, 22, 203, 169, 139, 116,
+			21, 236, 213, 212, 189, 211, 90, 101, 152, 157, 69, 148, 58, 3,
+			44, 189, 83, 211, 25, 157, 98, 118, 150, 157, 77, 189, 223, 212,
+			252, 221, 167, 139, 93, 149, 139, 11, 229, 89, 18, 158, 199, 86,
+			234, 165, 183, 151, 62, 93, 133, 239, 103, 231, 47, 82, 156, 45,
+			189, 117, 8, 148, 123, 144, 207, 117, 35, 223, 239, 149, 9, 166,
+			110, 255, 205, 73, 212, 251, 62, 69, 129, 154, 199, 30, 244, 103,
+			247, 213, 247, 48, 175, 188, 77, 152, 10, 5, 113, 65, 27, 217,
+			34, 125, 139, 82, 47, 173, 97, 250, 246, 152, 52, 103, 44, 215,
+			65, 125, 149, 206, 51, 130, 137, 124, 55, 78, 20, 51, 238, 25,
+			124, 24, 121, 205, 26, 61, 235, 115, 183, 78, 151, 10, 249, 41,
+			29, 37, 80, 11, 119, 170, 166, 139, 80, 231, 148, 188, 194, 139,
+			22, 234, 8, 174, 233, 85, 67, 63, 12, 166, 165, 59, 247, 152,
+			52, 63, 252, 92, 78, 154, 10, 198, 164, 249, 225, 231, 114, 242,
+			117, 162, 49, 105, 126, 248, 185, 220, 216, 29, 105, 130, 5, 9,
+			197, 67, 105, 66, 30, 18, 14, 159, 166, 5, 154, 151, 9, 38,
+			164, 28, 57, 69, 127, 211, 148, 147, 221, 112, 200, 167, 96, 178,
+			255, 130, 169, 174, 30, 54, 240, 41, 2, 177, 81, 79, 26, 17,
+			231, 34, 120, 124, 59, 210, 122, 214, 5, 25, 247, 217, 247, 2,
+			216, 23, 224, 239, 106, 232, 183, 155, 65, 153, 50, 88, 170, 225,
+			67, 170, 184, 150, 51, 142, 155, 110, 28, 183, 155, 188, 38, 150,
+			101, 55, 206, 32, 154, 46, 99, 81, 129, 71, 191, 115, 224, 70,
+			250, 246, 144, 23, 136, 64, 169, 98, 169, 16, 180, 199, 75, 59,
+			242, 62, 80, 181, 83, 97, 25, 215, 88, 192, 41, 248, 79, 160,
+			212, 7, 32, 128, 242, 41, 30, 133, 51, 194, 172, 15, 154, 135,
+			118, 93, 238, 132, 109, 177, 76, 236, 202, 251, 236, 110, 173, 70,
+			217, 89, 188, 133, 4, 130, 75, 158, 131, 212, 188, 184, 229, 187,
+			29, 79, 61, 12, 217, 22, 183, 41, 21, 221, 13, 130, 68, 77,
+			199, 206, 176, 33, 33, 51, 118, 6, 146, 61, 51, 118, 134, 5,
+			9, 153, 177, 51, 242, 144, 144, 25, 59, 3, 198, 238, 83, 48,
+			118, 31, 29, 146, 99, 103, 58, 228, 43, 57, 50, 93, 124, 215,
+			144, 142, 104, 190, 142, 59, 78, 88, 216, 150, 131, 122, 216, 109,
+			242, 211, 113, 77, 51, 28, 171, 226, 31, 187, 24, 229, 169, 3,
+			219, 209, 166, 212, 165, 81, 95, 241, 210, 195, 31, 92, 180, 241,
+			201, 70, 33, 79, 83, 174, 151, 187, 118, 44, 160, 188, 111, 213,
+			187, 28, 138, 225, 179, 75, 59, 165, 108, 30, 67, 169, 202, 37,
+			16, 141, 220, 123, 235, 13, 3, 116, 151, 229, 213, 54, 94, 8,
+			131, 108, 177, 136, 96, 201, 130, 80, 240, 20, 21, 230, 167, 158,
+			82, 98, 25, 214, 249, 197, 161, 94, 34, 45, 205, 248, 190, 166,
+			222, 9, 96, 165, 208, 32, 159, 187, 160, 79, 108, 214, 184, 104,
+			247, 166, 110, 16, 34, 184, 193, 121, 11, 22, 63, 119, 59, 114,
+			91, 13, 108, 182, 206, 128, 236, 38, 26, 64, 21, 177, 166, 182,
+			218, 9, 234, 77, 213, 48, 8, 132, 59, 121, 18, 78, 11, 27,
+			183, 112, 5, 87, 179, 169, 34, 150, 68, 141, 27, 125, 252, 149,
+			37, 118, 171, 35, 130, 240, 244, 118, 38, 76, 41, 150, 46, 166,
+			66, 85, 72, 119, 11, 250, 2, 242, 42, 94, 222, 104, 164, 69,
+			228, 253, 138, 108, 112, 148, 139, 250, 99, 211, 141, 110, 192, 60,
+			17, 22, 240, 217, 217, 105, 177, 173, 138, 241, 89, 74, 142, 250,
+			191, 84, 248, 132, 154, 170, 232, 80, 86, 52, 4, 126, 72, 100,
+			192, 37, 100, 154, 128, 185, 113, 194, 35, 47, 190, 145, 62, 118,
+			171, 209, 237, 149, 152, 184, 165, 195, 176, 156, 192, 24, 97, 122,
+			143, 79, 24, 8, 162, 56, 169, 80, 118, 149, 239, 34, 77, 144,
+			115, 229, 181, 205, 244, 202, 39, 190, 155, 36, 94, 227, 80, 111,
+			7, 116, 45, 44, 24, 34, 68, 219, 17, 112, 237, 93, 144, 221,
+			207, 50, 110, 61, 12, 113, 95, 124, 139, 207, 91, 110, 84, 217,
+			7, 237, 150, 27, 137, 213, 111, 191, 181, 108, 203, 125, 138, 221,
+			207, 206, 93, 124, 65, 180, 79, 169, 90, 231, 3, 169, 153, 3,
+			37, 246, 228, 121, 1, 28, 111, 104, 223, 148, 56, 94, 12, 147,
+			202, 153, 121, 87, 181, 189, 229, 115, 72, 23, 234, 129, 68, 176,
+			40, 39, 134, 230, 19, 241, 4, 78, 180, 157, 121, 36, 20, 24,
+			94, 49, 65, 24, 177, 36, 114, 61, 188, 64, 160, 88, 68, 162,
+			18, 181, 50, 85, 62, 251, 78, 103, 36, 68, 209, 150, 239, 6,
+			55, 4, 211, 171, 217, 32, 47, 76, 10, 13, 16, 209, 192, 142,
+			162, 242, 226, 205, 75, 167, 22, 155, 171, 236, 59, 38, 34, 219,
+			253, 236, 110, 49, 42, 167, 216, 165, 44, 99, 107, 106, 161, 234,
+			118, 74, 68, 104, 199, 110, 179, 21, 217, 87, 197, 222, 177, 204,
+			162, 152, 92, 42, 27, 21, 118, 106, 246, 5, 49, 203, 93, 4,
+			59, 197, 182, 35, 140, 87, 40, 11, 244, 48, 150, 248, 200, 238,
+			103, 247, 232, 81, 145, 254, 9, 172, 214, 211, 253, 56, 179, 28,
+			97, 176, 143, 236, 114, 132, 225, 62, 114, 242, 36, 3, 19, 12,
+			72, 24, 63, 145, 38, 88, 144, 48, 57, 133, 39, 25, 144, 96,
+			57, 228, 143, 115, 228, 148, 206, 96, 17, 76, 72, 113, 90, 54,
+			36, 100, 112, 90, 6, 36, 140, 159, 76, 19, 16, 199, 212, 180,
+			198, 73, 28, 242, 213, 28, 153, 211, 25, 136, 72, 72, 113, 18,
+			27, 18, 50, 56, 137, 1, 9, 227, 51, 105, 130, 5, 9, 103,
+			206, 210, 47, 27, 212, 36, 227, 78, 238, 47, 114, 125, 63, 216,
+			111, 20, 127, 207, 200, 60, 232, 38, 164, 162, 47, 182, 89, 13,
+			175, 197, 182, 120, 178, 203, 121, 208, 115, 173, 72, 236, 183, 147,
+			184, 215, 60, 173, 222, 11, 152, 79, 195, 143, 235, 245, 53, 27,
+			68, 36, 142, 195, 170, 231, 234, 51, 47, 253, 76, 138, 174, 133,
+			102, 237, 221, 233, 97, 185, 10, 247, 143, 26, 38, 6, 101, 7,
+			22, 75, 189, 132, 101, 161, 174, 48, 17, 194, 52, 61, 110, 56,
+			228, 47, 114, 249, 131, 244, 38, 37, 100, 28, 246, 109, 223, 204,
+			153, 39, 139, 79, 178, 249, 128, 205, 107, 119, 23, 181, 4, 197,
+			98, 171, 143, 54, 0, 80, 73, 249, 77, 92, 51, 122, 136, 128,
+			38, 57, 181, 166, 80, 101, 7, 81, 241, 132, 130, 237, 238, 119,
+			43, 112, 15, 57, 142, 202, 234, 55, 213, 126, 116, 28, 247, 163,
+			223, 204, 13, 222, 166, 64, 3, 192, 219, 153, 2, 45, 0, 143,
+			159, 192, 253, 232, 56, 236, 71, 255, 42, 103, 190, 189, 95, 236,
+			71, 199, 113, 63, 250, 87, 57, 58, 78, 255, 39, 131, 230, 0,
+			134, 126, 61, 211, 79, 202, 197, 239, 201, 110, 71, 209, 71, 179,
+			123, 229, 235, 61, 86, 232, 122, 58, 57, 125, 118, 10, 253, 139,
+			133, 21, 29, 151, 101, 101, 36, 114, 123, 181, 167, 138, 178, 238,
+			87, 132, 253, 8, 185, 110, 92, 234, 230, 207, 244, 75, 70, 29,
+			151, 186, 249, 51, 253, 82, 191, 27, 151, 186, 249, 51, 253, 82,
+			191, 27, 151, 186, 249, 51, 253, 82, 191, 27, 151, 186, 249, 51,
+			253, 82, 191, 27, 87, 186, 249, 51, 253, 71, 78, 209, 85, 217,
+			111, 195, 33, 111, 238, 39, 39, 138, 15, 244, 246, 27, 121, 0,
+			3, 52, 139, 189, 137, 84, 177, 246, 239, 127, 166, 221, 160, 151,
+			190, 57, 219, 110, 208, 75, 223, 220, 47, 39, 216, 184, 212, 75,
+			223, 220, 63, 126, 52, 77, 176, 32, 161, 116, 156, 238, 202, 70,
+			153, 14, 121, 182, 159, 28, 41, 110, 247, 54, 10, 53, 121, 177,
+			122, 215, 99, 142, 131, 178, 213, 73, 210, 247, 75, 187, 249, 76,
+			6, 165, 198, 105, 153, 250, 191, 103, 14, 16, 197, 57, 124, 166,
+			241, 32, 46, 158, 205, 54, 30, 164, 216, 179, 89, 162, 3, 193,
+			158, 237, 151, 15, 194, 142, 75, 41, 246, 108, 255, 161, 195, 244,
+			179, 138, 149, 44, 135, 188, 173, 159, 28, 42, 254, 154, 177, 135,
+			151, 164, 91, 217, 75, 105, 188, 188, 64, 249, 2, 141, 71, 44,
+			34, 78, 11, 15, 20, 156, 57, 233, 4, 185, 208, 114, 227, 36,
+			179, 13, 141, 184, 207, 119, 240, 81, 237, 78, 194, 217, 148, 124,
+			175, 77, 132, 22, 80, 91, 72, 156, 179, 247, 35, 202, 25, 161,
+			47, 77, 103, 40, 132, 47, 188, 102, 41, 4, 50, 249, 109, 89,
+			10, 225, 43, 175, 253, 242, 65, 219, 113, 41, 147, 223, 214, 127,
+			176, 72, 63, 114, 39, 61, 42, 98, 144, 207, 186, 45, 111, 246,
+			255, 35, 239, 221, 163, 227, 200, 206, 251, 192, 190, 85, 213, 141,
+			198, 5, 64, 2, 151, 32, 0, 22, 95, 151, 77, 114, 0, 144,
+			141, 198, 131, 228, 12, 135, 28, 142, 212, 0, 154, 100, 143, 64,
+			0, 211, 0, 56, 26, 41, 18, 166, 208, 93, 0, 74, 236, 238,
+			106, 85, 85, 131, 132, 38, 218, 99, 175, 101, 197, 177, 180, 146,
+			252, 144, 163, 248, 165, 149, 44, 201, 103, 157, 196, 199, 246, 122,
+			163, 227, 172, 99, 199, 118, 188, 113, 214, 74, 28, 249, 216, 142,
+			87, 142, 226, 87, 236, 245, 99, 109, 173, 173, 156, 149, 143, 31,
+			217, 61, 247, 187, 143, 186, 213, 104, 144, 67, 69, 222, 61, 246,
+			206, 31, 67, 124, 183, 111, 221, 247, 253, 238, 119, 191, 251, 125,
+			191, 15, 54, 202, 166, 116, 28, 231, 11, 137, 96, 1, 82, 238,
+			180, 60, 91, 0, 150, 79, 75, 192, 242, 233, 216, 132, 130, 231,
+			190, 244, 25, 132, 7, 224, 230, 63, 47, 74, 33, 103, 176, 125,
+			187, 92, 90, 90, 220, 156, 47, 221, 45, 222, 47, 175, 84, 54,
+			55, 150, 215, 86, 75, 11, 229, 219, 229, 210, 226, 96, 138, 244,
+			227, 172, 140, 190, 61, 136, 24, 85, 41, 189, 188, 81, 174, 148,
+			22, 7, 13, 114, 20, 247, 173, 108, 172, 175, 110, 172, 111, 174,
+			44, 47, 189, 58, 104, 146, 35, 24, 151, 151, 21, 109, 145, 1,
+			220, 91, 190, 119, 111, 99, 189, 56, 191, 84, 26, 76, 19, 130,
+			143, 108, 44, 175, 84, 22, 75, 149, 210, 226, 230, 82, 121, 109,
+			125, 48, 67, 142, 227, 161, 229, 149, 229, 205, 210, 189, 213, 245,
+			87, 55, 23, 75, 183, 139, 27, 75, 235, 131, 61, 55, 94, 195,
+			71, 146, 221, 37, 167, 11, 157, 112, 236, 208, 17, 97, 166, 49,
+			246, 221, 89, 106, 78, 28, 153, 59, 81, 136, 199, 163, 144, 232,
+			105, 101, 96, 91, 39, 231, 91, 248, 72, 213, 111, 104, 217, 231,
+			73, 34, 63, 104, 68, 86, 209, 219, 138, 34, 199, 142, 95, 119,
+			154, 59, 5, 63, 216, 153, 222, 113, 155, 208, 136, 105, 254, 147,
+			211, 242, 66, 152, 32, 205, 154, 241, 166, 246, 247, 39, 13, 235,
+			78, 113, 181, 252, 210, 207, 219, 56, 67, 172, 35, 169, 87, 17,
+			254, 113, 11, 163, 126, 98, 30, 73, 145, 185, 31, 182, 232, 130,
+			223, 218, 15, 188, 157, 221, 136, 206, 205, 204, 94, 23, 150, 133,
+			116, 105, 105, 1, 99, 186, 228, 85, 221, 102, 8, 129, 251, 106,
+			226, 150, 87, 108, 49, 169, 66, 254, 146, 167, 247, 57, 16, 10,
+			157, 43, 204, 208, 9, 80, 89, 139, 159, 114, 147, 55, 49, 220,
+			158, 101, 52, 66, 45, 56, 31, 60, 143, 84, 221, 22, 108, 43,
+			142, 111, 232, 52, 171, 110, 12, 185, 40, 202, 40, 96, 128, 88,
+			132, 8, 193, 91, 112, 36, 178, 59, 107, 75, 186, 234, 202, 108,
+			212, 137, 48, 215, 162, 237, 70, 81, 235, 198, 244, 244, 195, 135,
+			15, 11, 14, 52, 20, 134, 172, 206, 179, 133, 211, 75, 229, 133,
+			210, 242, 90, 105, 106, 174, 48, 131, 49, 221, 104, 130, 47, 163,
+			242, 115, 220, 218, 151, 65, 243, 152, 180, 91, 119, 30, 130, 238,
+			113, 39, 16, 72, 79, 94, 83, 66, 133, 228, 105, 232, 111, 71,
+			15, 225, 18, 84, 243, 152, 200, 184, 213, 142, 18, 163, 36, 27,
+			38, 130, 110, 203, 12, 62, 196, 26, 206, 21, 215, 104, 121, 45,
+			71, 231, 139, 107, 229, 181, 60, 166, 175, 148, 215, 239, 174, 108,
+			172, 211, 87, 138, 149, 74, 113, 121, 189, 92, 90, 163, 43, 21,
+			186, 176, 178, 188, 88, 102, 107, 127, 141, 174, 220, 166, 197, 229,
+			87, 233, 91, 202, 203, 139, 121, 233, 215, 233, 62, 98, 247, 248,
+			16, 76, 21, 193, 110, 175, 166, 1, 126, 202, 234, 149, 229, 184,
+			196, 246, 87, 112, 88, 59, 254, 158, 27, 192, 213, 10, 128, 26,
+			66, 17, 182, 176, 89, 195, 20, 32, 75, 132, 6, 250, 64, 143,
+			10, 24, 103, 49, 50, 136, 57, 152, 58, 134, 123, 177, 97, 166,
+			136, 73, 82, 151, 88, 98, 150, 152, 195, 169, 183, 178, 196, 108,
+			31, 255, 147, 39, 30, 79, 229, 32, 17, 243, 63, 121, 226, 72,
+			234, 10, 36, 138, 63, 121, 226, 104, 106, 28, 18, 17, 255, 147,
+			39, 142, 137, 207, 47, 200, 63, 81, 15, 177, 236, 212, 4, 194,
+			95, 48, 177, 209, 147, 34, 230, 184, 113, 195, 254, 183, 38, 45,
+			10, 21, 188, 166, 237, 83, 253, 150, 144, 6, 2, 246, 98, 66,
+			206, 121, 94, 6, 212, 101, 178, 89, 30, 2, 177, 76, 130, 209,
+			149, 220, 231, 9, 124, 200, 228, 213, 177, 67, 21, 73, 223, 62,
+			161, 237, 254, 36, 255, 152, 164, 183, 168, 100, 93, 239, 128, 43,
+			197, 90, 228, 68, 34, 172, 199, 27, 249, 88, 227, 116, 252, 251,
+			78, 118, 180, 216, 22, 154, 151, 40, 170, 179, 2, 249, 118, 120,
+			66, 169, 49, 187, 236, 94, 232, 186, 215, 112, 195, 200, 105, 180,
+			216, 106, 243, 2, 119, 51, 242, 120, 95, 223, 80, 233, 90, 155,
+			243, 66, 199, 253, 132, 230, 72, 110, 253, 142, 155, 24, 99, 108,
+			246, 164, 12, 98, 218, 61, 231, 249, 223, 22, 155, 104, 145, 158,
+			33, 230, 120, 159, 72, 71, 196, 28, 191, 48, 199, 255, 54, 137,
+			57, 126, 237, 121, 252, 7, 6, 54, 210, 41, 98, 205, 164, 94,
+			69, 246, 23, 13, 112, 212, 110, 214, 188, 42, 32, 82, 9, 214,
+			161, 67, 160, 56, 34, 28, 57, 95, 37, 19, 219, 250, 203, 21,
+			87, 229, 104, 114, 162, 98, 23, 32, 160, 191, 187, 237, 134, 17,
+			247, 0, 230, 101, 56, 161, 92, 82, 0, 75, 33, 46, 99, 14,
+			147, 27, 90, 237, 104, 82, 122, 179, 95, 186, 36, 223, 209, 46,
+			93, 210, 241, 152, 85, 179, 228, 26, 172, 250, 117, 42, 66, 23,
+			138, 215, 242, 155, 236, 210, 203, 173, 40, 185, 73, 93, 152, 252,
+			146, 93, 90, 24, 199, 20, 40, 10, 187, 254, 67, 90, 92, 45,
+			131, 9, 7, 91, 175, 187, 78, 179, 86, 87, 98, 163, 4, 164,
+			3, 19, 244, 117, 229, 78, 117, 233, 82, 195, 217, 191, 116, 137,
+			6, 110, 213, 245, 246, 92, 0, 232, 76, 6, 159, 87, 239, 79,
+			24, 155, 105, 54, 9, 51, 105, 130, 223, 132, 173, 52, 147, 206,
+			205, 57, 227, 156, 61, 71, 23, 252, 230, 30, 147, 128, 184, 18,
+			65, 216, 47, 195, 232, 130, 175, 158, 230, 65, 12, 7, 3, 152,
+			167, 166, 211, 32, 43, 155, 115, 198, 41, 73, 25, 196, 156, 59,
+			75, 241, 39, 16, 148, 142, 136, 249, 156, 113, 212, 254, 54, 36,
+			144, 115, 132, 158, 86, 14, 133, 84, 241, 59, 161, 186, 43, 23,
+			48, 125, 5, 16, 22, 0, 143, 129, 195, 29, 116, 27, 94, 39,
+			112, 105, 172, 161, 230, 54, 180, 252, 237, 89, 240, 17, 129, 38,
+			66, 221, 70, 107, 215, 9, 61, 136, 193, 172, 133, 72, 84, 237,
+			71, 208, 70, 69, 25, 196, 124, 110, 224, 8, 254, 23, 188, 253,
+			6, 49, 95, 48, 142, 218, 63, 194, 238, 163, 7, 154, 44, 23,
+			151, 2, 61, 224, 203, 214, 213, 48, 60, 120, 222, 75, 151, 26,
+			237, 144, 173, 157, 45, 55, 70, 51, 118, 194, 196, 107, 156, 88,
+			157, 121, 174, 243, 222, 118, 188, 58, 68, 101, 245, 105, 205, 167,
+			161, 8, 125, 36, 158, 132, 155, 212, 13, 2, 198, 24, 219, 34,
+			62, 232, 107, 229, 229, 251, 197, 165, 242, 226, 102, 177, 114, 103,
+			227, 94, 105, 121, 253, 181, 73, 213, 61, 54, 5, 47, 168, 238,
+			65, 135, 6, 142, 224, 191, 226, 221, 51, 137, 57, 111, 16, 251,
+			75, 93, 187, 167, 49, 219, 39, 246, 80, 135, 105, 134, 173, 22,
+			182, 124, 118, 136, 231, 5, 222, 75, 181, 222, 150, 190, 12, 88,
+			127, 56, 22, 189, 78, 4, 150, 227, 146, 137, 8, 139, 205, 71,
+			78, 62, 208, 122, 17, 63, 245, 32, 21, 246, 35, 15, 233, 22,
+			143, 137, 195, 177, 28, 67, 0, 72, 210, 176, 234, 198, 67, 17,
+			117, 169, 234, 198, 99, 99, 34, 214, 255, 1, 73, 25, 196, 156,
+			31, 28, 194, 223, 203, 199, 198, 34, 230, 29, 99, 200, 254, 72,
+			215, 177, 1, 254, 240, 85, 14, 141, 228, 66, 226, 145, 27, 119,
+			188, 136, 171, 175, 216, 110, 211, 53, 163, 74, 43, 202, 155, 107,
+			33, 214, 192, 126, 73, 25, 196, 188, 115, 116, 16, 127, 31, 111,
+			124, 154, 152, 75, 198, 160, 253, 209, 238, 141, 111, 52, 218, 17,
+			19, 155, 158, 216, 118, 185, 163, 92, 214, 215, 170, 155, 156, 179,
+			200, 23, 230, 223, 212, 193, 96, 209, 205, 110, 173, 177, 94, 68,
+			192, 254, 112, 118, 89, 227, 202, 65, 120, 127, 80, 61, 72, 35,
+			214, 202, 62, 73, 25, 196, 92, 58, 114, 20, 127, 155, 1, 61,
+			200, 16, 179, 98, 28, 183, 191, 209, 80, 61, 16, 204, 125, 66,
+			106, 200, 39, 53, 248, 206, 38, 109, 55, 1, 38, 196, 173, 209,
+			186, 7, 26, 230, 195, 122, 38, 189, 255, 88, 27, 101, 240, 95,
+			77, 27, 161, 160, 124, 88, 49, 32, 93, 56, 205, 125, 234, 4,
+			91, 94, 20, 56, 193, 62, 229, 104, 36, 121, 26, 56, 73, 197,
+			182, 0, 41, 225, 225, 95, 2, 117, 153, 103, 140, 95, 69, 13,
+			163, 69, 133, 194, 83, 23, 193, 7, 88, 45, 227, 161, 248, 26,
+			68, 231, 64, 31, 189, 80, 204, 147, 24, 163, 12, 98, 163, 50,
+			40, 41, 131, 152, 149, 99, 195, 248, 235, 249, 136, 245, 16, 243,
+			173, 198, 152, 253, 101, 148, 28, 49, 13, 93, 131, 155, 195, 135,
+			34, 152, 33, 127, 153, 144, 156, 94, 160, 161, 109, 75, 148, 211,
+			67, 70, 80, 0, 194, 64, 31, 69, 191, 196, 5, 28, 74, 147,
+			65, 64, 226, 101, 194, 24, 154, 86, 29, 207, 16, 43, 88, 185,
+			125, 62, 191, 106, 67, 161, 58, 100, 148, 243, 16, 184, 252, 54,
+			152, 45, 227, 3, 165, 240, 40, 202, 92, 205, 192, 71, 164, 7,
+			177, 49, 56, 38, 41, 131, 152, 111, 29, 25, 221, 202, 112, 24,
+			63, 252, 191, 254, 29, 60, 229, 53, 183, 3, 103, 26, 44, 167,
+			118, 188, 166, 59, 253, 208, 117, 163, 45, 239, 17, 191, 8, 79,
+			239, 205, 78, 87, 253, 70, 195, 111, 202, 43, 179, 248, 185, 176,
+			55, 107, 63, 233, 126, 173, 50, 168, 59, 117, 36, 37, 50, 158,
+			33, 247, 16, 247, 50, 33, 173, 194, 54, 4, 121, 22, 103, 93,
+			39, 168, 123, 110, 24, 141, 33, 138, 38, 250, 230, 236, 3, 215,
+			86, 37, 210, 85, 84, 94, 50, 135, 51, 160, 182, 136, 198, 140,
+			39, 126, 37, 114, 230, 158, 197, 253, 235, 110, 24, 85, 128, 45,
+			150, 107, 100, 4, 103, 184, 254, 9, 106, 238, 173, 8, 138, 28,
+			193, 134, 87, 131, 114, 123, 43, 134, 87, 203, 189, 27, 247, 220,
+			119, 2, 207, 105, 70, 164, 128, 205, 154, 187, 61, 134, 168, 57,
+			209, 55, 119, 170, 16, 143, 75, 65, 228, 40, 44, 186, 219, 128,
+			32, 92, 97, 25, 237, 103, 113, 86, 38, 144, 65, 108, 62, 112,
+			247, 69, 93, 236, 79, 50, 140, 211, 48, 133, 162, 46, 78, 220,
+			48, 174, 163, 220, 85, 140, 215, 64, 84, 95, 117, 188, 224, 141,
+			126, 153, 91, 194, 195, 243, 237, 157, 245, 192, 169, 62, 240, 154,
+			59, 11, 210, 102, 232, 208, 142, 158, 194, 189, 202, 176, 72, 148,
+			20, 39, 228, 174, 227, 35, 171, 236, 8, 217, 106, 120, 81, 165,
+			221, 124, 138, 1, 107, 225, 129, 162, 210, 35, 207, 183, 119, 222,
+			232, 135, 132, 226, 222, 186, 215, 124, 176, 25, 185, 143, 162, 49,
+			147, 37, 207, 155, 191, 81, 52, 43, 89, 150, 186, 238, 62, 138,
+			200, 113, 108, 182, 131, 250, 152, 21, 255, 198, 232, 220, 243, 184,
+			119, 161, 222, 14, 35, 55, 40, 215, 88, 183, 156, 250, 142, 31,
+			120, 209, 174, 172, 48, 78, 56, 208, 216, 183, 242, 85, 113, 79,
+			132, 80, 33, 4, 91, 236, 122, 36, 62, 132, 191, 201, 85, 156,
+			149, 38, 25, 98, 189, 141, 233, 115, 207, 190, 151, 90, 214, 138,
+			202, 153, 91, 227, 37, 203, 95, 88, 201, 129, 219, 242, 101, 201,
+			236, 111, 114, 18, 247, 110, 123, 117, 119, 19, 170, 228, 141, 202,
+			178, 132, 101, 86, 45, 193, 86, 221, 107, 186, 48, 18, 233, 10,
+			252, 125, 233, 59, 17, 238, 155, 111, 123, 245, 26, 187, 141, 181,
+			67, 114, 10, 143, 205, 111, 148, 151, 22, 55, 215, 214, 139, 235,
+			27, 107, 29, 202, 168, 49, 60, 156, 248, 117, 109, 99, 97, 161,
+			180, 182, 54, 136, 14, 252, 114, 187, 88, 94, 218, 168, 148, 6,
+			13, 114, 6, 219, 137, 95, 202, 203, 183, 43, 69, 245, 187, 73,
+			78, 224, 227, 137, 223, 23, 138, 203, 11, 165, 165, 210, 226, 160,
+			117, 233, 195, 8, 15, 149, 30, 249, 18, 181, 191, 2, 65, 36,
+			72, 14, 159, 41, 189, 117, 101, 185, 84, 41, 174, 151, 87, 150,
+			55, 43, 165, 226, 218, 202, 114, 71, 67, 71, 48, 89, 89, 88,
+			216, 168, 172, 109, 174, 44, 111, 222, 43, 150, 151, 151, 202, 203,
+			165, 65, 68, 70, 241, 177, 56, 125, 101, 253, 110, 169, 178, 185,
+			176, 180, 54, 104, 144, 65, 220, 191, 188, 178, 190, 185, 80, 41,
+			175, 151, 23, 138, 75, 131, 38, 57, 134, 143, 110, 44, 151, 222,
+			186, 90, 90, 88, 47, 45, 110, 174, 22, 215, 214, 6, 173, 75,
+			239, 192, 71, 215, 96, 5, 71, 110, 237, 182, 87, 143, 220, 128,
+			80, 124, 106, 109, 99, 254, 94, 121, 157, 101, 187, 93, 94, 90,
+			47, 117, 170, 240, 8, 62, 194, 110, 128, 155, 42, 219, 32, 34,
+			195, 120, 16, 210, 54, 150, 227, 84, 227, 82, 13, 15, 234, 123,
+			228, 158, 95, 115, 89, 119, 87, 43, 37, 158, 107, 179, 178, 177,
+			188, 121, 111, 101, 177, 212, 81, 67, 31, 238, 89, 172, 188, 202,
+			126, 229, 58, 194, 219, 27, 75, 75, 64, 25, 100, 8, 15, 188,
+			188, 81, 94, 120, 203, 166, 204, 96, 94, 250, 36, 194, 68, 175,
+			70, 76, 253, 5, 76, 147, 21, 117, 93, 2, 7, 154, 163, 47,
+			133, 210, 34, 116, 238, 44, 62, 217, 53, 15, 155, 116, 80, 92,
+			158, 195, 167, 187, 102, 80, 115, 111, 206, 231, 223, 118, 233, 73,
+			167, 202, 77, 145, 208, 218, 122, 233, 159, 175, 224, 30, 146, 182,
+			82, 31, 49, 16, 254, 1, 4, 186, 61, 43, 69, 230, 62, 142,
+			18, 186, 189, 185, 89, 56, 17, 23, 118, 3, 191, 225, 181, 27,
+			180, 8, 16, 128, 128, 47, 94, 167, 144, 41, 84, 62, 198, 5,
+			12, 232, 146, 210, 147, 48, 225, 180, 16, 10, 221, 145, 80, 152,
+			209, 249, 181, 197, 169, 48, 218, 7, 219, 127, 174, 117, 130, 83,
+			157, 67, 250, 178, 251, 82, 187, 169, 94, 225, 133, 254, 77, 62,
+			21, 113, 77, 82, 38, 214, 36, 101, 83, 147, 240, 39, 34, 102,
+			111, 106, 82, 40, 128, 250, 82, 69, 169, 84, 98, 127, 94, 192,
+			134, 149, 34, 214, 145, 212, 48, 178, 199, 104, 145, 6, 112, 119,
+			102, 77, 149, 135, 84, 200, 111, 165, 22, 187, 70, 30, 201, 14,
+			225, 23, 176, 5, 158, 177, 230, 160, 49, 105, 79, 115, 80, 218,
+			122, 13, 228, 77, 165, 224, 208, 145, 158, 133, 213, 4, 43, 151,
+			139, 1, 236, 235, 12, 251, 252, 164, 164, 16, 49, 7, 79, 93,
+			144, 148, 73, 204, 193, 113, 30, 20, 14, 238, 167, 199, 140, 113,
+			251, 5, 90, 22, 229, 113, 160, 76, 213, 56, 33, 116, 242, 240,
+			180, 30, 119, 126, 170, 215, 98, 185, 79, 222, 131, 89, 81, 25,
+			86, 150, 172, 148, 221, 42, 143, 157, 202, 73, 202, 36, 230, 177,
+			139, 207, 224, 9, 30, 75, 121, 52, 149, 67, 246, 41, 241, 132,
+			193, 177, 12, 29, 202, 78, 106, 113, 115, 17, 67, 194, 202, 24,
+			205, 14, 227, 87, 176, 5, 24, 167, 166, 109, 12, 219, 47, 113,
+			71, 129, 56, 115, 40, 131, 125, 98, 186, 16, 163, 20, 70, 50,
+			168, 233, 158, 83, 247, 106, 49, 214, 109, 142, 127, 84, 219, 202,
+			137, 134, 35, 35, 149, 102, 37, 103, 37, 133, 136, 105, 247, 30,
+			149, 148, 73, 76, 155, 28, 195, 31, 51, 160, 13, 136, 152, 231,
+			140, 65, 251, 155, 13, 90, 94, 84, 250, 74, 173, 45, 10, 207,
+			191, 107, 243, 110, 179, 15, 244, 95, 188, 38, 229, 50, 201, 226,
+			124, 94, 120, 114, 64, 64, 27, 47, 188, 129, 105, 206, 107, 238,
+			73, 227, 218, 233, 215, 203, 203, 247, 87, 22, 56, 27, 45, 47,
+			190, 119, 154, 21, 19, 78, 191, 190, 81, 89, 218, 44, 173, 45,
+			20, 87, 75, 139, 155, 235, 165, 181, 117, 248, 77, 148, 62, 253,
+			58, 219, 183, 75, 144, 150, 195, 244, 21, 48, 78, 72, 20, 147,
+			167, 93, 190, 135, 107, 153, 250, 18, 166, 94, 232, 82, 132, 93,
+			44, 214, 155, 173, 6, 17, 165, 217, 208, 200, 65, 100, 51, 119,
+			174, 183, 79, 82, 38, 49, 207, 29, 57, 138, 63, 135, 48, 187,
+			167, 89, 19, 169, 60, 178, 127, 18, 81, 33, 57, 37, 173, 188,
+			30, 58, 251, 60, 190, 23, 183, 165, 17, 235, 162, 234, 128, 54,
+			23, 230, 30, 94, 59, 85, 170, 130, 227, 126, 228, 86, 219, 2,
+			54, 61, 126, 124, 126, 232, 236, 11, 88, 66, 105, 197, 239, 55,
+			177, 246, 251, 202, 90, 158, 222, 89, 221, 144, 118, 89, 241, 15,
+			194, 147, 87, 249, 122, 250, 129, 12, 48, 77, 183, 235, 206, 142,
+			220, 181, 108, 69, 76, 100, 143, 226, 143, 32, 108, 89, 6, 91,
+			163, 151, 141, 51, 246, 127, 203, 93, 18, 99, 155, 221, 24, 30,
+			149, 203, 138, 220, 102, 234, 129, 187, 63, 197, 23, 102, 203, 241,
+			130, 196, 48, 0, 114, 163, 211, 112, 35, 55, 16, 222, 189, 91,
+			160, 14, 243, 31, 198, 235, 235, 161, 19, 178, 54, 9, 67, 39,
+			209, 19, 161, 22, 22, 243, 98, 0, 43, 184, 108, 28, 151, 20,
+			34, 230, 229, 145, 19, 146, 50, 137, 121, 249, 212, 105, 140, 121,
+			72, 243, 66, 234, 26, 130, 78, 49, 142, 86, 200, 18, 252, 22,
+			108, 89, 38, 235, 211, 172, 49, 100, 191, 72, 43, 238, 142, 251,
+			232, 6, 125, 231, 219, 157, 169, 247, 188, 131, 253, 111, 102, 234,
+			249, 205, 119, 92, 154, 152, 238, 72, 152, 188, 116, 1, 211, 123,
+			206, 35, 241, 248, 119, 131, 62, 123, 85, 52, 199, 132, 189, 54,
+			43, 150, 137, 9, 205, 153, 237, 237, 151, 148, 73, 204, 217, 163,
+			131, 248, 44, 84, 139, 136, 121, 213, 56, 102, 147, 68, 73, 115,
+			215, 158, 85, 69, 177, 21, 119, 85, 21, 197, 86, 220, 213, 222,
+			35, 146, 50, 137, 121, 117, 136, 224, 37, 30, 217, 253, 122, 170,
+			132, 236, 55, 119, 240, 155, 173, 246, 14, 141, 132, 196, 28, 155,
+			217, 243, 187, 91, 226, 55, 185, 127, 97, 108, 44, 68, 204, 235,
+			217, 83, 160, 103, 176, 44, 54, 56, 47, 24, 195, 246, 71, 249,
+			132, 119, 249, 172, 155, 185, 191, 23, 198, 203, 23, 76, 234, 227,
+			167, 87, 44, 61, 201, 191, 122, 6, 215, 240, 155, 126, 224, 120,
+			117, 201, 224, 44, 24, 244, 23, 196, 72, 89, 48, 232, 47, 8,
+			6, 103, 193, 160, 191, 64, 142, 225, 191, 52, 160, 63, 136, 152,
+			139, 198, 168, 253, 199, 198, 193, 254, 196, 67, 244, 53, 237, 82,
+			89, 104, 184, 187, 12, 29, 196, 20, 227, 157, 137, 67, 223, 74,
+			55, 123, 222, 20, 39, 228, 42, 36, 126, 117, 134, 231, 233, 208,
+			117, 169, 23, 113, 184, 110, 154, 43, 51, 201, 228, 69, 38, 141,
+			191, 120, 187, 238, 60, 240, 154, 110, 24, 230, 10, 60, 226, 141,
+			86, 54, 52, 0, 199, 45, 104, 5, 254, 187, 220, 106, 36, 246,
+			86, 174, 42, 228, 144, 220, 164, 84, 76, 9, 192, 102, 174, 238,
+			145, 86, 193, 194, 118, 84, 41, 96, 229, 113, 32, 74, 27, 15,
+			233, 43, 92, 14, 130, 8, 85, 222, 78, 91, 25, 148, 242, 201,
+			96, 75, 122, 81, 77, 20, 91, 210, 139, 189, 68, 82, 38, 49,
+			23, 143, 143, 224, 151, 49, 216, 21, 221, 77, 173, 33, 187, 212,
+			177, 164, 91, 82, 84, 228, 124, 193, 169, 135, 62, 125, 208, 244,
+			31, 54, 185, 90, 48, 183, 240, 50, 173, 180, 155, 57, 198, 204,
+			114, 11, 247, 225, 239, 73, 177, 174, 211, 136, 152, 119, 179, 35,
+			248, 31, 178, 117, 157, 102, 235, 122, 201, 24, 182, 63, 192, 215,
+			181, 152, 15, 14, 142, 237, 8, 63, 54, 8, 112, 234, 87, 221,
+			80, 4, 29, 213, 235, 126, 131, 75, 181, 222, 174, 122, 83, 213,
+			189, 28, 48, 232, 165, 141, 133, 50, 216, 2, 122, 17, 189, 239,
+			6, 108, 0, 3, 76, 39, 120, 242, 125, 201, 209, 210, 176, 154,
+			151, 196, 32, 165, 97, 53, 47, 137, 213, 156, 134, 213, 188, 68,
+			142, 225, 127, 201, 123, 193, 181, 69, 246, 63, 69, 137, 113, 234,
+			214, 218, 114, 103, 114, 188, 4, 69, 3, 18, 7, 180, 148, 53,
+			101, 87, 192, 248, 62, 247, 58, 203, 186, 185, 90, 89, 121, 169,
+			180, 176, 254, 222, 105, 78, 46, 220, 135, 3, 88, 194, 199, 83,
+			118, 174, 51, 97, 249, 250, 243, 215, 175, 95, 159, 125, 254, 234,
+			179, 87, 174, 95, 187, 58, 53, 59, 181, 253, 252, 213, 231, 174,
+			204, 109, 187, 115, 51, 51, 215, 158, 221, 174, 205, 230, 84, 135,
+			217, 170, 168, 168, 14, 179, 85, 81, 17, 71, 107, 26, 86, 69,
+			229, 200, 81, 252, 60, 134, 80, 46, 247, 83, 30, 178, 167, 186,
+			48, 58, 201, 213, 166, 186, 115, 181, 12, 34, 230, 253, 236, 113,
+			188, 131, 45, 43, 195, 38, 255, 85, 99, 216, 126, 27, 93, 83,
+			131, 112, 216, 22, 245, 213, 238, 141, 173, 40, 249, 155, 47, 214,
+			56, 17, 44, 184, 173, 246, 142, 3, 142, 246, 178, 103, 25, 152,
+			202, 87, 69, 207, 50, 48, 149, 175, 138, 169, 204, 192, 84, 190,
+			74, 142, 225, 127, 132, 160, 77, 136, 152, 239, 52, 6, 237, 239,
+			97, 83, 249, 152, 6, 77, 169, 71, 84, 175, 99, 198, 89, 19,
+			113, 151, 125, 31, 86, 119, 221, 6, 44, 199, 215, 197, 54, 125,
+			239, 244, 235, 205, 118, 195, 13, 188, 234, 166, 87, 123, 111, 158,
+			7, 1, 85, 173, 239, 252, 72, 203, 170, 250, 197, 102, 236, 157,
+			170, 95, 108, 198, 222, 41, 102, 44, 3, 51, 246, 206, 35, 71,
+			113, 8, 221, 50, 136, 181, 101, 84, 103, 108, 151, 22, 233, 110,
+			187, 225, 52, 167, 2, 215, 169, 193, 147, 62, 60, 213, 74, 25,
+			51, 49, 192, 177, 189, 63, 240, 62, 54, 12, 225, 174, 31, 68,
+			117, 175, 249, 64, 113, 172, 128, 125, 82, 245, 27, 211, 179, 115,
+			87, 174, 94, 123, 246, 185, 220, 164, 106, 158, 145, 38, 230, 150,
+			106, 30, 27, 218, 45, 193, 102, 50, 134, 97, 18, 115, 235, 248,
+			136, 164, 178, 196, 172, 90, 211, 248, 40, 206, 2, 245, 221, 217,
+			20, 49, 171, 233, 2, 110, 65, 235, 77, 98, 237, 24, 187, 51,
+			246, 150, 8, 73, 5, 160, 38, 53, 104, 208, 70, 101, 73, 46,
+			249, 18, 52, 104, 55, 138, 90, 225, 141, 233, 233, 173, 246, 78,
+			88, 144, 252, 20, 12, 30, 90, 211, 146, 156, 246, 194, 176, 237,
+			134, 211, 53, 55, 114, 188, 250, 155, 188, 218, 45, 222, 248, 120,
+			197, 152, 105, 98, 238, 168, 166, 51, 65, 101, 71, 200, 15, 25,
+			195, 52, 137, 185, 115, 116, 80, 82, 89, 98, 238, 170, 166, 155,
+			188, 233, 187, 233, 2, 254, 199, 38, 6, 40, 225, 48, 245, 119,
+			145, 253, 113, 147, 42, 245, 145, 46, 128, 197, 6, 81, 114, 23,
+			85, 121, 54, 174, 169, 5, 158, 176, 42, 206, 8, 47, 196, 212,
+			23, 177, 66, 156, 80, 250, 23, 234, 71, 130, 10, 107, 21, 91,
+			139, 131, 9, 251, 163, 40, 1, 210, 191, 29, 185, 77, 206, 91,
+			189, 166, 112, 0, 83, 143, 99, 226, 86, 171, 67, 166, 136, 22,
+			65, 216, 17, 161, 217, 111, 242, 123, 176, 28, 107, 249, 128, 237,
+			181, 10, 53, 119, 111, 122, 118, 110, 110, 146, 181, 176, 42, 48,
+			122, 64, 207, 238, 10, 239, 123, 14, 16, 13, 234, 238, 61, 175,
+			214, 118, 234, 240, 58, 23, 118, 111, 1, 95, 100, 145, 31, 135,
+			92, 225, 150, 96, 74, 217, 6, 221, 152, 20, 38, 169, 242, 204,
+			172, 185, 161, 23, 192, 218, 150, 145, 22, 101, 75, 0, 134, 7,
+			31, 172, 72, 176, 167, 30, 68, 204, 48, 59, 132, 223, 137, 45,
+			171, 135, 177, 167, 61, 99, 212, 126, 153, 22, 85, 109, 130, 33,
+			116, 25, 29, 184, 67, 168, 124, 92, 133, 175, 137, 38, 90, 78,
+			177, 198, 122, 128, 43, 237, 137, 53, 214, 3, 92, 105, 79, 108,
+			143, 30, 224, 74, 123, 199, 71, 240, 175, 32, 104, 10, 34, 230,
+			235, 198, 160, 253, 115, 58, 87, 18, 197, 197, 213, 4, 9, 24,
+			158, 196, 48, 9, 189, 127, 179, 230, 6, 245, 125, 112, 182, 210,
+			190, 242, 192, 217, 182, 225, 135, 17, 157, 125, 150, 219, 243, 137,
+			231, 196, 100, 104, 24, 110, 117, 206, 14, 248, 93, 247, 145, 83,
+			115, 171, 94, 195, 169, 99, 105, 241, 225, 111, 211, 54, 104, 12,
+			174, 204, 209, 186, 255, 208, 13, 224, 210, 164, 229, 164, 213, 93,
+			39, 112, 170, 17, 135, 61, 227, 221, 100, 12, 236, 117, 53, 4,
+			140, 129, 189, 46, 24, 88, 15, 48, 176, 215, 143, 28, 197, 191,
+			96, 130, 221, 66, 230, 253, 40, 245, 81, 132, 236, 127, 97, 82,
+			77, 59, 41, 167, 82, 123, 134, 4, 124, 35, 142, 193, 2, 30,
+			171, 82, 34, 220, 115, 131, 26, 196, 234, 116, 66, 5, 68, 36,
+			31, 95, 196, 221, 78, 202, 27, 108, 181, 85, 213, 27, 76, 226,
+			107, 120, 169, 81, 30, 66, 124, 125, 10, 187, 124, 204, 93, 50,
+			183, 3, 71, 61, 43, 251, 1, 43, 186, 234, 214, 235, 194, 254,
+			166, 10, 94, 45, 181, 73, 254, 42, 249, 80, 139, 179, 216, 110,
+			242, 55, 22, 88, 45, 157, 237, 5, 9, 139, 195, 47, 72, 13,
+			211, 182, 240, 25, 128, 238, 198, 214, 10, 197, 122, 93, 222, 163,
+			121, 80, 118, 105, 103, 157, 208, 168, 10, 121, 86, 160, 42, 179,
+			187, 55, 88, 110, 122, 85, 254, 42, 94, 247, 30, 184, 60, 142,
+			73, 50, 194, 41, 184, 70, 76, 139, 96, 95, 46, 156, 72, 194,
+			36, 74, 121, 174, 9, 123, 171, 67, 57, 195, 179, 220, 38, 27,
+			224, 181, 222, 143, 210, 131, 120, 78, 88, 71, 88, 223, 132, 140,
+			179, 246, 5, 90, 20, 19, 40, 95, 158, 165, 139, 186, 7, 78,
+			112, 81, 91, 96, 230, 113, 131, 8, 246, 145, 45, 73, 131, 145,
+			167, 207, 224, 11, 194, 34, 194, 250, 0, 50, 78, 218, 35, 226,
+			134, 193, 202, 12, 219, 213, 42, 4, 221, 80, 101, 32, 158, 109,
+			68, 146, 6, 35, 79, 216, 248, 156, 176, 74, 176, 62, 196, 202,
+			56, 166, 149, 193, 198, 93, 43, 128, 213, 243, 161, 184, 0, 254,
+			201, 9, 27, 191, 89, 188, 251, 91, 223, 130, 192, 234, 35, 46,
+			128, 73, 244, 237, 102, 4, 79, 168, 114, 193, 240, 120, 113, 108,
+			84, 197, 202, 81, 229, 155, 8, 138, 56, 37, 73, 131, 145, 103,
+			41, 30, 23, 111, 231, 214, 183, 35, 227, 148, 125, 66, 43, 159,
+			173, 23, 190, 232, 180, 102, 90, 8, 114, 142, 74, 210, 96, 164,
+			125, 18, 127, 156, 109, 46, 68, 50, 223, 131, 82, 255, 12, 33,
+			251, 35, 38, 61, 160, 93, 167, 85, 167, 197, 227, 134, 56, 34,
+			104, 51, 125, 184, 187, 47, 21, 38, 114, 165, 63, 116, 66, 76,
+			93, 241, 173, 91, 43, 168, 114, 32, 210, 35, 15, 121, 227, 234,
+			185, 99, 43, 160, 102, 141, 214, 60, 136, 21, 45, 66, 30, 111,
+			243, 104, 36, 236, 52, 115, 170, 17, 224, 41, 49, 238, 207, 189,
+			201, 28, 133, 185, 40, 250, 27, 249, 80, 44, 101, 210, 83, 224,
+			178, 147, 82, 254, 190, 176, 68, 183, 92, 32, 220, 48, 146, 224,
+			22, 29, 18, 57, 166, 243, 224, 222, 232, 55, 213, 154, 229, 231,
+			66, 82, 118, 136, 37, 7, 16, 170, 249, 255, 47, 79, 55, 28,
+			175, 121, 99, 199, 159, 14, 131, 234, 244, 142, 159, 252, 132, 93,
+			58, 166, 165, 22, 48, 126, 247, 100, 109, 217, 20, 234, 71, 72,
+			124, 211, 187, 111, 29, 24, 243, 103, 162, 253, 150, 123, 171, 42,
+			28, 24, 210, 108, 149, 126, 15, 74, 143, 224, 231, 177, 149, 134,
+			40, 76, 31, 71, 198, 69, 251, 50, 45, 38, 39, 225, 73, 123,
+			134, 199, 101, 250, 56, 50, 168, 36, 13, 70, 158, 191, 128, 127,
+			200, 128, 146, 17, 177, 62, 131, 140, 19, 246, 39, 12, 186, 198,
+			81, 110, 52, 190, 164, 238, 212, 0, 129, 233, 111, 9, 228, 77,
+			159, 141, 42, 27, 8, 112, 153, 218, 10, 156, 102, 117, 23, 11,
+			240, 18, 0, 173, 8, 35, 184, 19, 120, 26, 32, 71, 187, 25,
+			202, 55, 19, 97, 184, 16, 138, 16, 74, 181, 201, 2, 166, 19,
+			76, 124, 134, 104, 99, 96, 0, 235, 111, 131, 179, 85, 36, 47,
+			231, 92, 133, 28, 114, 168, 132, 109, 118, 219, 174, 239, 115, 123,
+			21, 21, 66, 63, 148, 33, 106, 57, 199, 101, 139, 224, 96, 71,
+			10, 147, 152, 22, 69, 224, 166, 200, 239, 214, 83, 48, 136, 18,
+			235, 101, 122, 225, 101, 182, 100, 66, 97, 141, 34, 70, 16, 241,
+			33, 27, 150, 164, 193, 200, 209, 49, 252, 127, 240, 1, 53, 136,
+			245, 131, 200, 176, 237, 47, 62, 197, 128, 234, 149, 178, 26, 39,
+			194, 73, 144, 207, 129, 199, 230, 49, 109, 55, 185, 69, 125, 141,
+			46, 44, 77, 132, 147, 5, 58, 33, 37, 59, 17, 219, 200, 219,
+			83, 210, 9, 8, 186, 98, 23, 136, 147, 28, 83, 0, 160, 145,
+			243, 213, 86, 98, 19, 232, 10, 130, 6, 151, 46, 99, 31, 51,
+			129, 231, 2, 162, 36, 188, 91, 128, 252, 176, 237, 84, 57, 34,
+			18, 59, 43, 184, 190, 16, 162, 42, 1, 40, 107, 27, 130, 12,
+			46, 44, 133, 116, 47, 236, 250, 27, 238, 92, 48, 238, 215, 100,
+			50, 216, 250, 253, 65, 100, 28, 151, 36, 140, 254, 216, 9, 110,
+			133, 133, 24, 55, 254, 81, 100, 28, 179, 255, 24, 41, 221, 126,
+			28, 168, 188, 26, 120, 176, 102, 98, 207, 243, 48, 162, 97, 123,
+			75, 83, 207, 44, 44, 77, 210, 150, 3, 241, 120, 148, 224, 32,
+			61, 21, 164, 111, 30, 160, 86, 212, 156, 200, 225, 224, 4, 92,
+			161, 85, 175, 75, 215, 214, 88, 144, 165, 222, 54, 86, 21, 133,
+			226, 233, 3, 132, 86, 183, 9, 8, 183, 92, 162, 111, 56, 53,
+			55, 110, 219, 132, 163, 73, 240, 220, 166, 201, 17, 22, 205, 108,
+			184, 2, 15, 80, 171, 234, 178, 241, 94, 4, 56, 129, 98, 56,
+			216, 81, 242, 163, 200, 56, 34, 73, 131, 145, 67, 4, 255, 6,
+			31, 29, 139, 88, 63, 134, 140, 17, 251, 243, 168, 243, 229, 131,
+			139, 32, 77, 125, 78, 216, 48, 20, 232, 68, 236, 70, 14, 65,
+			129, 1, 212, 69, 177, 50, 184, 176, 0, 104, 76, 34, 104, 180,
+			132, 178, 100, 141, 238, 40, 145, 9, 158, 109, 110, 127, 30, 233,
+			65, 18, 117, 85, 87, 228, 139, 71, 129, 53, 118, 11, 21, 215,
+			86, 169, 70, 107, 56, 205, 182, 86, 9, 91, 30, 110, 60, 0,
+			236, 16, 252, 49, 100, 12, 73, 210, 96, 228, 240, 113, 252, 251,
+			8, 27, 86, 150, 100, 126, 10, 165, 126, 14, 33, 251, 11, 136,
+			150, 15, 224, 177, 241, 227, 14, 92, 178, 225, 28, 90, 169, 242,
+			231, 195, 60, 245, 162, 113, 30, 227, 239, 145, 83, 141, 148, 33,
+			62, 227, 254, 5, 201, 253, 229, 19, 190, 52, 1, 16, 120, 194,
+			15, 221, 113, 46, 107, 177, 78, 129, 231, 52, 71, 161, 169, 119,
+			209, 232, 183, 36, 104, 7, 228, 131, 20, 216, 151, 94, 157, 221,
+			28, 185, 163, 205, 67, 87, 5, 96, 174, 121, 123, 110, 176, 227,
+			210, 154, 255, 80, 188, 20, 5, 78, 245, 129, 240, 135, 203, 34,
+			98, 253, 20, 202, 14, 51, 33, 194, 202, 2, 58, 46, 50, 136,
+			16, 34, 148, 119, 20, 44, 0, 113, 57, 26, 192, 105, 150, 49,
+			13, 57, 179, 146, 68, 140, 236, 29, 144, 164, 201, 200, 193, 33,
+			136, 248, 145, 101, 91, 241, 95, 49, 209, 164, 34, 94, 132, 212,
+			158, 242, 212, 11, 143, 208, 157, 74, 87, 45, 81, 89, 12, 131,
+			18, 184, 45, 159, 222, 219, 88, 91, 79, 88, 155, 170, 230, 0,
+			198, 173, 88, 207, 140, 132, 26, 143, 142, 74, 210, 100, 164, 125,
+			18, 207, 96, 195, 234, 37, 153, 159, 71, 169, 47, 34, 100, 231,
+			98, 44, 58, 95, 19, 239, 117, 192, 49, 24, 163, 94, 68, 172,
+			159, 103, 99, 244, 107, 236, 6, 214, 203, 6, 233, 243, 108, 144,
+			62, 135, 232, 29, 47, 242, 234, 110, 72, 55, 42, 75, 82, 142,
+			214, 110, 83, 16, 4, 137, 221, 155, 124, 174, 6, 106, 56, 220,
+			186, 87, 251, 234, 134, 18, 53, 94, 216, 245, 195, 232, 197, 233,
+			23, 196, 250, 125, 49, 1, 37, 23, 235, 50, 148, 92, 193, 197,
+			105, 41, 160, 248, 141, 88, 46, 9, 131, 106, 14, 211, 123, 82,
+			0, 112, 155, 66, 236, 207, 21, 118, 188, 40, 199, 164, 242, 187,
+			43, 27, 75, 139, 221, 70, 178, 23, 38, 246, 243, 114, 98, 123,
+			97, 98, 63, 47, 39, 182, 23, 38, 246, 243, 108, 98, 255, 148,
+			15, 5, 34, 214, 255, 134, 140, 81, 251, 183, 17, 93, 214, 46,
+			194, 2, 172, 239, 144, 185, 22, 58, 49, 112, 201, 147, 118, 107,
+			45, 63, 244, 34, 63, 216, 207, 115, 193, 133, 99, 92, 240, 86,
+			79, 79, 231, 148, 103, 193, 13, 70, 198, 248, 68, 211, 45, 103,
+			31, 12, 8, 167, 171, 126, 224, 74, 106, 83, 152, 193, 109, 178,
+			253, 181, 217, 142, 188, 250, 102, 187, 201, 4, 140, 48, 42, 84,
+			97, 104, 180, 231, 157, 107, 179, 115, 5, 204, 87, 150, 52, 181,
+			222, 114, 170, 15, 194, 186, 19, 238, 66, 228, 243, 138, 178, 56,
+			22, 99, 128, 210, 208, 105, 57, 66, 8, 198, 160, 151, 72, 210,
+			100, 228, 241, 17, 38, 245, 91, 16, 88, 255, 63, 32, 99, 200,
+			158, 163, 43, 77, 87, 192, 103, 192, 89, 39, 208, 91, 30, 51,
+			72, 162, 64, 35, 13, 69, 244, 72, 18, 49, 50, 219, 47, 73,
+			147, 145, 71, 7, 177, 139, 141, 180, 65, 50, 191, 137, 82, 127,
+			128, 144, 253, 10, 237, 48, 68, 97, 163, 29, 1, 26, 139, 118,
+			159, 12, 5, 76, 55, 136, 241, 234, 250, 185, 79, 119, 157, 154,
+			46, 150, 97, 41, 151, 9, 17, 148, 53, 225, 55, 81, 122, 24,
+			68, 80, 131, 109, 135, 223, 70, 198, 121, 251, 178, 10, 175, 204,
+			163, 241, 168, 247, 125, 167, 94, 79, 86, 43, 184, 48, 188, 13,
+			178, 111, 207, 72, 210, 96, 228, 185, 28, 94, 129, 130, 17, 177,
+			126, 23, 25, 199, 237, 34, 71, 99, 144, 230, 7, 201, 30, 192,
+			137, 83, 243, 155, 227, 66, 210, 237, 34, 78, 170, 234, 16, 47,
+			113, 80, 146, 6, 35, 143, 13, 227, 50, 84, 103, 16, 235, 247,
+			217, 90, 190, 249, 196, 234, 158, 88, 17, 107, 250, 239, 35, 131,
+			72, 18, 138, 62, 62, 130, 63, 96, 96, 35, 109, 146, 204, 151,
+			80, 234, 47, 16, 178, 255, 18, 209, 78, 131, 30, 249, 242, 42,
+			44, 60, 27, 44, 233, 224, 59, 75, 33, 126, 234, 192, 241, 28,
+			138, 87, 3, 144, 171, 224, 67, 237, 90, 78, 217, 161, 240, 215,
+			113, 171, 169, 238, 129, 117, 230, 150, 183, 243, 238, 182, 27, 236,
+			179, 107, 141, 19, 69, 110, 163, 21, 95, 105, 18, 22, 70, 29,
+			215, 25, 38, 136, 124, 9, 165, 143, 227, 27, 216, 74, 155, 108,
+			45, 253, 9, 187, 206, 228, 105, 177, 227, 101, 233, 73, 247, 25,
+			120, 217, 101, 31, 83, 73, 26, 140, 60, 127, 1, 207, 64, 201,
+			136, 88, 255, 25, 25, 71, 236, 28, 173, 180, 155, 106, 73, 134,
+			32, 244, 214, 184, 155, 5, 175, 77, 149, 135, 248, 39, 189, 146,
+			52, 24, 217, 63, 128, 175, 65, 121, 6, 177, 190, 130, 140, 163,
+			246, 120, 71, 121, 236, 10, 203, 177, 221, 121, 208, 214, 142, 66,
+			89, 59, 190, 130, 12, 69, 66, 49, 3, 71, 240, 44, 20, 106,
+			18, 235, 207, 145, 49, 108, 159, 135, 66, 193, 247, 252, 73, 173,
+			100, 3, 248, 231, 200, 56, 42, 73, 131, 145, 228, 24, 254, 20,
+			187, 205, 91, 36, 243, 62, 35, 245, 17, 3, 217, 31, 53, 233,
+			65, 163, 46, 169, 62, 20, 142, 188, 124, 60, 187, 45, 183, 39,
+			171, 148, 186, 26, 108, 29, 80, 45, 97, 169, 91, 250, 127, 67,
+			181, 244, 215, 123, 155, 175, 238, 77, 203, 208, 18, 211, 65, 187,
+			57, 29, 70, 126, 224, 236, 8, 147, 224, 155, 245, 91, 115, 215,
+			223, 244, 238, 91, 108, 71, 94, 156, 155, 89, 184, 127, 113, 110,
+			134, 143, 238, 197, 185, 153, 186, 211, 220, 185, 33, 253, 204, 217,
+			30, 96, 178, 232, 251, 140, 244, 40, 232, 191, 44, 182, 7, 222,
+			111, 24, 227, 79, 161, 255, 130, 7, 118, 246, 81, 78, 146, 6,
+			35, 47, 62, 131, 115, 80, 34, 34, 214, 223, 55, 140, 139, 246,
+			48, 127, 51, 97, 75, 171, 67, 251, 5, 47, 191, 44, 19, 149,
+			164, 193, 200, 243, 23, 240, 89, 40, 193, 32, 214, 7, 13, 35,
+			103, 15, 169, 18, 18, 186, 47, 11, 214, 245, 7, 13, 142, 178,
+			205, 72, 248, 128, 158, 195, 23, 225, 115, 147, 88, 31, 54, 140,
+			11, 246, 168, 250, 188, 139, 102, 202, 130, 181, 252, 97, 195, 56,
+			43, 73, 131, 145, 185, 243, 202, 152, 252, 251, 179, 248, 218, 19,
+			141, 201, 133, 2, 100, 147, 235, 167, 14, 26, 149, 231, 22, 240,
+			192, 109, 158, 71, 152, 140, 206, 225, 227, 173, 192, 107, 56, 193,
+			254, 38, 56, 180, 108, 10, 151, 70, 97, 59, 123, 76, 252, 88,
+			98, 191, 221, 227, 63, 61, 165, 1, 226, 239, 102, 112, 134, 88,
+			86, 106, 236, 111, 172, 253, 33, 183, 52, 204, 198, 150, 134, 236,
+			207, 57, 110, 105, 216, 151, 26, 67, 246, 51, 93, 46, 74, 29,
+			186, 65, 152, 105, 105, 119, 216, 151, 61, 142, 63, 109, 73, 195,
+			195, 81, 227, 188, 253, 15, 44, 238, 252, 15, 62, 69, 18, 37,
+			4, 154, 218, 174, 71, 94, 195, 129, 224, 50, 112, 179, 174, 197,
+			2, 147, 208, 248, 137, 135, 74, 142, 22, 128, 249, 203, 255, 150,
+			240, 49, 73, 148, 7, 160, 205, 28, 83, 64, 243, 245, 1, 127,
+			147, 48, 114, 170, 15, 224, 114, 4, 114, 95, 177, 169, 132, 239,
+			135, 242, 102, 31, 105, 49, 92, 225, 89, 205, 105, 210, 18, 248,
+			105, 195, 229, 148, 137, 178, 47, 57, 123, 242, 126, 72, 203, 124,
+			80, 225, 94, 46, 60, 105, 180, 193, 160, 53, 238, 79, 161, 1,
+			226, 178, 251, 175, 136, 135, 195, 87, 40, 184, 44, 237, 99, 234,
+			53, 26, 110, 205, 227, 67, 176, 237, 176, 107, 189, 212, 243, 197,
+			0, 9, 213, 93, 63, 116, 155, 224, 105, 153, 140, 250, 11, 248,
+			79, 184, 91, 241, 210, 10, 65, 143, 95, 13, 222, 30, 2, 127,
+			78, 58, 3, 10, 159, 36, 249, 38, 34, 159, 155, 52, 53, 46,
+			150, 209, 88, 148, 197, 156, 247, 30, 117, 29, 144, 67, 38, 35,
+			250, 186, 239, 110, 115, 229, 138, 31, 208, 176, 225, 212, 235, 210,
+			184, 115, 118, 102, 238, 170, 66, 145, 192, 116, 99, 253, 246, 212,
+			245, 216, 194, 52, 205, 214, 73, 86, 179, 48, 29, 237, 61, 163,
+			89, 152, 142, 158, 203, 41, 78, 241, 219, 63, 142, 240, 244, 147,
+			253, 78, 120, 63, 194, 199, 120, 158, 28, 230, 88, 98, 63, 157,
+			87, 139, 61, 251, 228, 236, 32, 67, 130, 35, 21, 255, 228, 171,
+			99, 117, 185, 255, 27, 225, 35, 226, 125, 185, 194, 239, 65, 100,
+			12, 247, 136, 91, 165, 96, 105, 146, 36, 119, 113, 191, 166, 132,
+			14, 199, 12, 240, 53, 185, 168, 251, 27, 36, 203, 42, 196, 78,
+			45, 149, 190, 72, 253, 29, 218, 223, 132, 48, 142, 127, 35, 103,
+			113, 159, 188, 133, 69, 206, 142, 168, 22, 139, 164, 117, 103, 135,
+			140, 226, 30, 168, 89, 185, 71, 100, 24, 89, 174, 145, 55, 227,
+			35, 201, 126, 129, 71, 66, 223, 220, 9, 189, 81, 9, 6, 94,
+			25, 216, 214, 201, 220, 231, 76, 124, 84, 181, 154, 59, 69, 18,
+			23, 143, 136, 217, 118, 107, 155, 137, 46, 115, 247, 154, 233, 174,
+			93, 230, 31, 75, 218, 173, 105, 157, 31, 174, 30, 76, 12, 201,
+			18, 38, 241, 35, 240, 230, 30, 135, 126, 16, 94, 28, 167, 187,
+			84, 225, 53, 119, 4, 62, 68, 101, 168, 218, 153, 100, 127, 131,
+			129, 143, 117, 169, 251, 201, 131, 251, 86, 156, 149, 107, 91, 76,
+			233, 11, 79, 217, 63, 153, 198, 221, 139, 84, 105, 246, 187, 113,
+			191, 254, 11, 185, 138, 177, 248, 141, 205, 36, 119, 170, 58, 222,
+			165, 174, 114, 173, 210, 91, 85, 78, 51, 151, 177, 185, 213, 222,
+			17, 227, 146, 152, 216, 132, 43, 79, 133, 229, 202, 253, 47, 8,
+			15, 29, 24, 46, 50, 133, 137, 122, 210, 14, 213, 72, 35, 112,
+			94, 25, 138, 127, 145, 217, 223, 132, 7, 130, 118, 221, 13, 59,
+			230, 228, 113, 158, 92, 253, 240, 129, 44, 160, 136, 143, 112, 3,
+			58, 85, 130, 249, 196, 18, 6, 248, 23, 162, 136, 220, 29, 60,
+			58, 239, 68, 213, 221, 59, 110, 36, 250, 19, 202, 29, 58, 130,
+			51, 28, 55, 91, 250, 44, 113, 138, 12, 227, 52, 8, 207, 48,
+			139, 189, 21, 78, 228, 222, 130, 199, 14, 22, 36, 22, 250, 180,
+			54, 245, 124, 105, 31, 235, 54, 245, 42, 83, 238, 211, 105, 220,
+			35, 82, 187, 186, 35, 157, 197, 125, 187, 78, 184, 41, 206, 66,
+			24, 183, 108, 5, 239, 58, 161, 80, 201, 176, 54, 70, 94, 84,
+			231, 158, 67, 189, 21, 78, 144, 183, 227, 19, 237, 208, 13, 54,
+			171, 245, 112, 147, 159, 119, 155, 234, 78, 1, 30, 85, 125, 115,
+			180, 75, 195, 10, 247, 220, 40, 240, 170, 247, 225, 142, 81, 25,
+			97, 69, 44, 212, 195, 219, 80, 128, 186, 192, 144, 45, 124, 74,
+			234, 193, 55, 229, 73, 185, 25, 191, 56, 142, 165, 223, 96, 249,
+			182, 44, 69, 240, 147, 48, 126, 169, 36, 47, 224, 172, 44, 122,
+			44, 243, 6, 203, 83, 95, 144, 123, 248, 188, 251, 238, 182, 183,
+			231, 212, 221, 102, 36, 219, 184, 41, 1, 207, 60, 191, 185, 201,
+			86, 215, 88, 15, 12, 25, 141, 179, 138, 134, 20, 227, 140, 149,
+			118, 221, 181, 191, 206, 192, 253, 122, 77, 228, 205, 184, 199, 111,
+			186, 155, 53, 103, 95, 172, 228, 241, 39, 53, 174, 176, 224, 183,
+			155, 81, 88, 201, 248, 77, 119, 209, 217, 39, 139, 184, 23, 160,
+			106, 161, 12, 243, 233, 202, 200, 194, 151, 162, 148, 208, 221, 115,
+			155, 80, 138, 245, 148, 165, 192, 151, 139, 206, 190, 157, 195, 25,
+			158, 198, 142, 170, 166, 223, 240, 154, 78, 29, 22, 161, 89, 145,
+			100, 238, 42, 62, 115, 199, 141, 42, 110, 204, 38, 87, 3, 127,
+			39, 112, 67, 181, 137, 186, 172, 222, 220, 79, 32, 60, 220, 237,
+			155, 174, 75, 61, 143, 73, 75, 252, 190, 217, 114, 131, 205, 134,
+			87, 23, 43, 62, 93, 25, 148, 191, 172, 186, 193, 61, 150, 78,
+			102, 177, 85, 119, 194, 72, 44, 182, 39, 112, 119, 200, 202, 62,
+			105, 186, 143, 34, 177, 158, 158, 244, 9, 203, 154, 123, 15, 62,
+			245, 114, 219, 13, 246, 197, 239, 107, 237, 70, 195, 9, 60, 55,
+			124, 242, 217, 126, 49, 62, 72, 185, 242, 79, 28, 180, 242, 180,
+			20, 30, 106, 39, 112, 22, 60, 161, 55, 183, 246, 197, 14, 238,
+			1, 122, 126, 63, 183, 139, 79, 31, 82, 183, 96, 54, 119, 176,
+			60, 181, 54, 67, 249, 163, 224, 58, 118, 151, 206, 241, 2, 246,
+			43, 131, 213, 142, 2, 115, 223, 110, 40, 161, 69, 100, 250, 42,
+			79, 22, 197, 140, 12, 157, 25, 137, 243, 166, 139, 32, 113, 240,
+			188, 33, 151, 241, 144, 226, 84, 155, 220, 138, 32, 132, 165, 109,
+			178, 69, 32, 117, 40, 60, 157, 188, 249, 13, 112, 34, 243, 177,
+			124, 198, 238, 224, 51, 102, 204, 69, 114, 215, 240, 73, 125, 2,
+			228, 215, 79, 56, 53, 114, 239, 76, 174, 153, 248, 51, 49, 109,
+			47, 106, 85, 242, 217, 202, 233, 99, 178, 232, 133, 145, 215, 172,
+			70, 201, 207, 181, 102, 253, 78, 6, 143, 116, 207, 164, 139, 117,
+			40, 33, 214, 77, 225, 30, 225, 137, 34, 24, 214, 177, 46, 14,
+			205, 21, 153, 135, 29, 183, 16, 223, 4, 88, 37, 147, 189, 223,
+			200, 113, 171, 190, 96, 105, 100, 29, 15, 104, 243, 216, 110, 10,
+			246, 52, 253, 228, 174, 22, 116, 85, 89, 165, 191, 165, 81, 228,
+			18, 30, 242, 194, 77, 208, 203, 108, 202, 89, 133, 89, 206, 86,
+			142, 122, 33, 152, 163, 45, 136, 100, 82, 193, 253, 218, 27, 40,
+			155, 94, 54, 214, 133, 55, 208, 0, 221, 10, 36, 81, 6, 185,
+			129, 251, 121, 229, 92, 3, 4, 39, 200, 145, 185, 81, 189, 76,
+			205, 38, 174, 210, 183, 165, 185, 239, 94, 197, 35, 94, 115, 7,
+			12, 96, 54, 99, 79, 47, 54, 87, 89, 152, 171, 97, 249, 107,
+			89, 253, 88, 174, 145, 5, 124, 198, 11, 55, 187, 125, 8, 96,
+			198, 110, 109, 172, 23, 186, 127, 210, 11, 203, 7, 190, 159, 231,
+			89, 200, 117, 220, 23, 95, 112, 194, 49, 12, 35, 49, 146, 216,
+			206, 234, 231, 138, 158, 149, 237, 104, 48, 146, 26, 235, 3, 62,
+			204, 9, 123, 17, 247, 105, 99, 68, 174, 225, 140, 184, 44, 32,
+			24, 143, 4, 107, 61, 96, 82, 83, 17, 153, 237, 239, 70, 184,
+			95, 159, 107, 178, 136, 7, 19, 107, 38, 102, 61, 9, 126, 150,
+			116, 85, 175, 28, 105, 37, 93, 215, 135, 113, 218, 127, 216, 84,
+			12, 151, 19, 100, 6, 91, 13, 191, 198, 23, 242, 145, 164, 63,
+			127, 231, 43, 64, 5, 114, 206, 253, 137, 137, 179, 82, 192, 35,
+			139, 177, 152, 214, 141, 183, 10, 158, 96, 159, 124, 140, 160, 159,
+			75, 145, 87, 112, 86, 138, 142, 228, 124, 98, 209, 116, 151, 76,
+			237, 11, 143, 207, 164, 10, 246, 240, 232, 33, 199, 51, 185, 164,
+			23, 241, 248, 51, 220, 78, 200, 88, 221, 50, 230, 82, 164, 137,
+			143, 119, 61, 150, 200, 132, 254, 241, 227, 78, 77, 123, 242, 13,
+			228, 84, 93, 123, 128, 135, 187, 177, 83, 50, 126, 88, 33, 29,
+			124, 218, 62, 180, 93, 157, 156, 57, 151, 122, 74, 197, 226, 151,
+			191, 19, 113, 215, 230, 223, 54, 31, 171, 90, 156, 251, 155, 166,
+			90, 20, 254, 206, 56, 246, 119, 238, 75, 61, 7, 127, 26, 196,
+			236, 79, 221, 132, 63, 77, 98, 14, 164, 222, 132, 191, 105, 0,
+			27, 153, 20, 177, 166, 82, 15, 145, 253, 23, 253, 116, 85, 98,
+			164, 200, 64, 193, 186, 202, 74, 115, 8, 227, 134, 183, 2, 55,
+			17, 158, 238, 193, 200, 144, 119, 221, 15, 93, 76, 149, 130, 8,
+			224, 247, 149, 149, 117, 72, 29, 10, 209, 122, 56, 40, 112, 108,
+			117, 199, 3, 12, 241, 184, 1, 13, 191, 25, 155, 57, 131, 17,
+			90, 161, 35, 58, 92, 194, 57, 173, 225, 168, 176, 45, 178, 158,
+			248, 49, 36, 46, 159, 27, 216, 236, 194, 91, 138, 170, 70, 153,
+			103, 128, 242, 79, 106, 6, 133, 86, 136, 174, 119, 86, 3, 22,
+			196, 91, 2, 50, 70, 89, 244, 203, 16, 52, 48, 177, 236, 222,
+			53, 213, 240, 107, 222, 182, 7, 118, 72, 178, 76, 237, 2, 67,
+			217, 5, 134, 78, 136, 64, 6, 82, 140, 10, 241, 129, 177, 216,
+			106, 239, 76, 22, 184, 46, 20, 204, 171, 67, 17, 4, 74, 229,
+			83, 129, 167, 90, 129, 223, 114, 3, 14, 144, 195, 221, 126, 133,
+			241, 250, 161, 213, 115, 116, 204, 134, 12, 144, 160, 27, 42, 9,
+			157, 165, 215, 164, 13, 167, 185, 175, 166, 146, 79, 58, 96, 229,
+			203, 113, 214, 17, 165, 27, 160, 110, 213, 103, 70, 69, 224, 145,
+			217, 119, 185, 5, 148, 116, 213, 200, 67, 196, 5, 56, 198, 69,
+			84, 170, 192, 213, 220, 5, 121, 188, 183, 3, 14, 28, 124, 182,
+			58, 60, 253, 192, 103, 105, 187, 93, 221, 13, 61, 39, 87, 144,
+			223, 197, 55, 3, 205, 251, 160, 195, 171, 32, 225, 84, 64, 139,
+			96, 197, 47, 176, 56, 49, 165, 116, 162, 216, 10, 188, 58, 48,
+			130, 201, 124, 210, 29, 128, 155, 155, 141, 179, 30, 179, 54, 77,
+			237, 93, 25, 7, 27, 21, 161, 229, 160, 87, 132, 18, 23, 98,
+			102, 185, 97, 52, 5, 14, 15, 221, 60, 29, 242, 116, 156, 47,
+			185, 67, 203, 136, 149, 197, 84, 154, 15, 119, 47, 136, 77, 209,
+			56, 168, 95, 166, 246, 230, 198, 149, 71, 18, 79, 225, 239, 221,
+			241, 135, 16, 181, 80, 121, 55, 240, 238, 214, 163, 93, 48, 156,
+			147, 79, 116, 245, 125, 217, 24, 183, 150, 143, 203, 210, 220, 52,
+			100, 91, 189, 144, 238, 122, 181, 154, 11, 161, 188, 0, 16, 159,
+			251, 139, 228, 41, 192, 11, 59, 17, 125, 189, 222, 174, 122, 155,
+			202, 113, 11, 10, 154, 126, 157, 253, 179, 233, 213, 222, 43, 195,
+			162, 37, 195, 29, 234, 78, 26, 218, 70, 3, 156, 100, 64, 69,
+			20, 159, 23, 38, 89, 238, 87, 92, 238, 170, 210, 244, 233, 78,
+			219, 9, 156, 102, 228, 178, 182, 170, 96, 160, 177, 137, 184, 191,
+			173, 117, 1, 148, 67, 121, 110, 194, 33, 95, 66, 104, 71, 28,
+			15, 191, 229, 188, 187, 237, 10, 71, 141, 144, 181, 65, 244, 143,
+			199, 122, 107, 198, 197, 77, 201, 118, 30, 116, 46, 137, 221, 194,
+			244, 220, 45, 183, 89, 3, 255, 216, 102, 141, 79, 137, 152, 131,
+			9, 7, 150, 163, 182, 4, 193, 182, 173, 155, 91, 8, 111, 86,
+			62, 126, 212, 128, 177, 211, 162, 144, 52, 101, 7, 184, 23, 100,
+			162, 249, 60, 192, 6, 27, 31, 190, 116, 198, 245, 21, 117, 136,
+			151, 12, 32, 95, 137, 25, 139, 93, 140, 161, 86, 110, 250, 89,
+			97, 92, 166, 188, 168, 92, 146, 24, 201, 77, 78, 120, 152, 182,
+			14, 111, 30, 140, 205, 76, 10, 17, 115, 42, 59, 136, 127, 183,
+			7, 91, 25, 120, 221, 42, 90, 47, 216, 191, 218, 211, 9, 159,
+			45, 190, 82, 38, 189, 77, 240, 17, 225, 161, 206, 180, 51, 101,
+			2, 204, 54, 249, 136, 87, 86, 23, 20, 160, 152, 86, 68, 168,
+			194, 127, 104, 140, 43, 79, 193, 58, 30, 171, 224, 41, 194, 34,
+			88, 155, 209, 152, 9, 228, 149, 119, 176, 91, 139, 119, 105, 39,
+			187, 21, 204, 83, 94, 5, 148, 169, 101, 194, 179, 24, 216, 62,
+			59, 156, 182, 216, 233, 239, 214, 104, 24, 57, 117, 183, 9, 80,
+			188, 210, 47, 136, 245, 181, 225, 53, 219, 145, 91, 160, 19, 21,
+			233, 169, 36, 123, 131, 37, 239, 86, 150, 45, 221, 43, 226, 129,
+			138, 222, 64, 179, 5, 183, 12, 15, 212, 78, 253, 118, 52, 229,
+			111, 79, 213, 156, 8, 108, 67, 5, 231, 140, 151, 106, 94, 202,
+			5, 28, 168, 142, 151, 169, 2, 252, 181, 67, 151, 247, 14, 203,
+			225, 217, 106, 71, 0, 233, 204, 35, 172, 240, 125, 234, 214, 232,
+			242, 202, 58, 171, 56, 112, 33, 190, 129, 8, 145, 37, 15, 187,
+			200, 199, 212, 169, 215, 253, 135, 52, 112, 37, 23, 18, 171, 111,
+			219, 225, 11, 54, 89, 191, 236, 77, 228, 215, 93, 198, 28, 192,
+			151, 110, 215, 219, 97, 130, 194, 203, 171, 107, 236, 200, 19, 224,
+			163, 180, 188, 205, 154, 19, 64, 75, 97, 183, 9, 252, 76, 182,
+			211, 124, 136, 111, 238, 212, 66, 44, 108, 154, 33, 146, 147, 8,
+			226, 84, 117, 234, 245, 120, 195, 104, 175, 143, 60, 156, 81, 187,
+			85, 115, 68, 100, 123, 214, 243, 201, 60, 166, 173, 186, 11, 208,
+			24, 236, 32, 173, 70, 202, 235, 28, 238, 62, 97, 129, 243, 52,
+			152, 85, 233, 141, 39, 129, 0, 1, 201, 144, 195, 27, 110, 215,
+			29, 112, 238, 15, 221, 58, 155, 110, 57, 78, 85, 23, 12, 173,
+			37, 130, 107, 59, 72, 108, 8, 16, 199, 90, 140, 27, 130, 5,
+			18, 96, 185, 130, 35, 137, 156, 166, 186, 235, 168, 152, 74, 124,
+			237, 198, 38, 159, 184, 67, 126, 219, 218, 87, 45, 231, 131, 194,
+			143, 0, 57, 14, 32, 31, 86, 157, 122, 181, 45, 98, 99, 8,
+			15, 215, 12, 127, 144, 44, 102, 143, 74, 202, 32, 102, 113, 240,
+			140, 164, 76, 98, 22, 39, 175, 227, 143, 90, 192, 18, 16, 177,
+			150, 173, 149, 41, 251, 253, 22, 173, 176, 9, 56, 36, 230, 51,
+			63, 25, 116, 25, 116, 85, 141, 49, 136, 56, 201, 49, 86, 65,
+			167, 106, 60, 192, 138, 194, 61, 132, 201, 12, 37, 104, 2, 102,
+			131, 150, 87, 214, 170, 201, 165, 85, 149, 102, 101, 226, 155, 45,
+			120, 18, 247, 233, 188, 183, 3, 247, 22, 30, 156, 40, 196, 96,
+			227, 157, 143, 189, 5, 194, 104, 202, 111, 69, 94, 195, 3, 79,
+			210, 109, 46, 114, 177, 233, 240, 132, 79, 89, 108, 193, 30, 219,
+			77, 11, 1, 69, 74, 39, 179, 87, 242, 226, 120, 128, 121, 231,
+			147, 9, 11, 148, 254, 55, 179, 244, 206, 60, 109, 185, 1, 95,
+			151, 224, 29, 13, 72, 142, 78, 192, 166, 51, 33, 90, 229, 85,
+			120, 74, 167, 25, 170, 104, 3, 14, 52, 146, 213, 58, 83, 184,
+			70, 55, 214, 104, 149, 31, 205, 172, 76, 94, 147, 19, 209, 86,
+			123, 171, 238, 133, 187, 192, 217, 188, 42, 52, 109, 99, 237, 194,
+			181, 233, 245, 121, 234, 52, 157, 250, 254, 123, 132, 169, 186, 28,
+			13, 96, 25, 11, 194, 103, 69, 98, 82, 176, 37, 41, 206, 47,
+			240, 13, 220, 231, 114, 240, 33, 166, 74, 115, 87, 102, 213, 10,
+			66, 136, 152, 203, 217, 65, 73, 25, 196, 92, 30, 154, 144, 148,
+			73, 204, 149, 161, 73, 252, 21, 4, 43, 200, 32, 214, 134, 117,
+			127, 210, 254, 61, 36, 86, 144, 100, 242, 82, 75, 77, 3, 119,
+			74, 147, 173, 226, 229, 36, 88, 105, 129, 86, 244, 12, 88, 56,
+			102, 169, 125, 157, 56, 93, 224, 104, 145, 50, 184, 199, 17, 142,
+			25, 55, 219, 22, 187, 148, 77, 4, 219, 70, 248, 80, 65, 157,
+			137, 79, 157, 199, 79, 66, 230, 19, 208, 234, 218, 79, 93, 224,
+			42, 50, 28, 90, 119, 35, 123, 86, 82, 6, 49, 55, 232, 139,
+			146, 50, 137, 121, 127, 232, 34, 254, 119, 89, 24, 35, 147, 88,
+			13, 171, 57, 103, 255, 84, 150, 190, 44, 86, 34, 87, 82, 239,
+			119, 219, 111, 126, 43, 177, 211, 192, 132, 129, 71, 123, 77, 50,
+			8, 176, 129, 80, 104, 180, 130, 171, 232, 205, 22, 119, 223, 216,
+			75, 33, 118, 82, 75, 170, 227, 133, 201, 133, 47, 99, 144, 241,
+			247, 117, 44, 252, 218, 59, 206, 68, 101, 169, 226, 203, 192, 100,
+			130, 25, 213, 220, 192, 219, 211, 98, 130, 97, 97, 233, 27, 31,
+			136, 161, 226, 150, 112, 202, 176, 162, 246, 24, 15, 220, 73, 152,
+			190, 195, 249, 17, 70, 138, 199, 201, 122, 217, 122, 15, 253, 6,
+			235, 145, 138, 127, 171, 95, 201, 184, 127, 11, 247, 166, 19, 43,
+			171, 35, 176, 173, 232, 45, 151, 237, 32, 60, 78, 46, 249, 192,
+			127, 35, 12, 119, 115, 194, 200, 102, 219, 99, 12, 166, 94, 239,
+			112, 169, 14, 133, 161, 52, 99, 43, 124, 165, 169, 11, 45, 199,
+			207, 166, 57, 40, 132, 95, 228, 189, 206, 75, 112, 94, 218, 253,
+			60, 4, 23, 164, 45, 79, 160, 34, 139, 206, 202, 177, 99, 199,
+			121, 184, 27, 207, 53, 95, 234, 250, 93, 177, 160, 199, 213, 222,
+			110, 215, 33, 16, 52, 214, 71, 180, 185, 19, 131, 234, 183, 2,
+			127, 171, 238, 54, 56, 250, 57, 103, 210, 187, 94, 139, 241, 67,
+			39, 212, 93, 14, 254, 127, 205, 219, 185, 76, 54, 59, 243, 181,
+			227, 240, 188, 196, 175, 41, 151, 231, 12, 198, 68, 196, 108, 100,
+			79, 75, 202, 32, 102, 227, 204, 13, 73, 153, 196, 108, 14, 21,
+			240, 7, 12, 96, 62, 22, 177, 218, 214, 222, 172, 253, 151, 72,
+			49, 31, 177, 37, 96, 120, 226, 37, 214, 60, 120, 212, 255, 45,
+			90, 13, 124, 108, 44, 68, 204, 118, 246, 148, 164, 12, 98, 182,
+			79, 95, 151, 148, 73, 204, 189, 161, 41, 220, 7, 182, 132, 233,
+			253, 212, 71, 16, 82, 70, 130, 251, 217, 17, 252, 75, 8, 91,
+			86, 202, 76, 145, 244, 123, 173, 191, 143, 210, 246, 191, 68, 52,
+			54, 83, 137, 29, 146, 15, 178, 116, 39, 121, 27, 10, 219, 34,
+			232, 105, 4, 18, 183, 146, 233, 68, 204, 33, 97, 233, 38, 216,
+			176, 132, 56, 16, 34, 171, 251, 168, 229, 192, 93, 198, 223, 115,
+			3, 128, 42, 236, 212, 229, 137, 119, 171, 68, 51, 196, 199, 113,
+			145, 177, 96, 46, 108, 218, 76, 214, 201, 247, 246, 143, 224, 255,
+			9, 225, 12, 35, 141, 20, 177, 190, 30, 101, 207, 218, 159, 66,
+			116, 133, 223, 116, 35, 103, 135, 134, 109, 238, 16, 43, 117, 6,
+			85, 176, 153, 203, 139, 177, 86, 135, 133, 39, 28, 1, 37, 20,
+			124, 65, 170, 66, 65, 252, 117, 194, 208, 11, 5, 146, 86, 157,
+			115, 42, 133, 25, 207, 101, 20, 137, 147, 94, 192, 116, 81, 10,
+			207, 210, 139, 108, 203, 165, 237, 166, 247, 238, 182, 91, 160, 43,
+			42, 108, 0, 4, 69, 74, 137, 104, 94, 95, 143, 178, 71, 226,
+			4, 196, 18, 142, 218, 113, 130, 201, 18, 78, 159, 193, 123, 162,
+			167, 136, 88, 223, 136, 178, 39, 237, 237, 248, 94, 28, 36, 252,
+			176, 38, 0, 238, 191, 229, 7, 145, 238, 234, 183, 56, 63, 41,
+			198, 94, 170, 205, 228, 193, 19, 230, 149, 105, 116, 16, 137, 30,
+			53, 189, 230, 187, 156, 27, 211, 211, 90, 83, 81, 26, 42, 142,
+			155, 138, 160, 37, 71, 71, 226, 4, 147, 37, 156, 176, 241, 13,
+			209, 84, 64, 19, 200, 94, 181, 47, 241, 149, 146, 56, 90, 146,
+			77, 246, 182, 217, 17, 53, 169, 85, 103, 100, 224, 99, 26, 39,
+			0, 84, 193, 185, 233, 56, 193, 100, 9, 115, 87, 240, 138, 176,
+			134, 181, 62, 128, 172, 81, 187, 120, 80, 105, 200, 184, 82, 7,
+			146, 133, 80, 118, 106, 86, 160, 210, 150, 75, 56, 16, 241, 185,
+			249, 0, 178, 20, 9, 48, 7, 125, 68, 146, 38, 35, 143, 143,
+			224, 93, 1, 206, 105, 125, 24, 89, 147, 246, 219, 14, 66, 94,
+			198, 90, 244, 2, 45, 74, 144, 144, 153, 153, 153, 131, 122, 236,
+			173, 88, 117, 46, 96, 244, 155, 154, 128, 35, 42, 70, 22, 84,
+			213, 47, 201, 12, 35, 7, 70, 37, 9, 13, 25, 187, 32, 73,
+			147, 145, 227, 76, 26, 102, 220, 36, 243, 173, 40, 245, 35, 8,
+			129, 119, 30, 203, 249, 173, 40, 59, 138, 223, 132, 45, 11, 153,
+			41, 146, 249, 118, 100, 125, 6, 165, 5, 144, 169, 166, 101, 113,
+			146, 176, 153, 161, 84, 250, 66, 27, 69, 187, 16, 4, 211, 251,
+			118, 212, 127, 22, 223, 197, 25, 70, 178, 18, 191, 3, 101, 63,
+			142, 122, 237, 103, 101, 160, 20, 129, 86, 163, 244, 40, 79, 40,
+			152, 205, 52, 148, 132, 136, 245, 29, 104, 240, 20, 222, 199, 89,
+			158, 192, 102, 251, 31, 162, 254, 25, 123, 71, 160, 178, 176, 29,
+			166, 43, 168, 146, 98, 80, 140, 26, 230, 232, 56, 52, 57, 16,
+			177, 115, 73, 45, 87, 121, 145, 43, 232, 66, 95, 92, 210, 105,
+			121, 177, 128, 241, 16, 238, 149, 85, 103, 160, 110, 170, 39, 33,
+			150, 116, 238, 178, 158, 100, 178, 164, 194, 52, 254, 113, 164, 26,
+			141, 136, 245, 189, 168, 127, 202, 254, 31, 98, 8, 194, 206, 80,
+			142, 90, 75, 242, 148, 239, 138, 88, 118, 226, 177, 89, 90, 126,
+			171, 93, 87, 238, 191, 74, 208, 75, 188, 67, 28, 118, 159, 200,
+			99, 218, 249, 240, 160, 73, 220, 241, 107, 137, 207, 223, 31, 18,
+			253, 102, 43, 237, 123, 81, 255, 5, 61, 9, 122, 116, 113, 66,
+			79, 50, 89, 210, 229, 60, 254, 70, 196, 215, 1, 155, 170, 79,
+			50, 214, 220, 126, 50, 103, 142, 3, 11, 243, 53, 223, 133, 15,
+			99, 61, 187, 228, 201, 238, 225, 28, 89, 46, 34, 216, 206, 159,
+			148, 252, 11, 137, 73, 251, 164, 100, 181, 72, 76, 217, 39, 25,
+			171, 93, 16, 45, 71, 196, 250, 52, 202, 94, 182, 175, 232, 59,
+			34, 60, 192, 68, 18, 11, 151, 85, 175, 47, 94, 216, 175, 159,
+			70, 217, 193, 56, 33, 195, 18, 134, 78, 199, 9, 80, 207, 153,
+			103, 226, 4, 147, 37, 76, 94, 194, 239, 55, 4, 156, 174, 245,
+			79, 144, 121, 211, 254, 10, 74, 54, 164, 83, 117, 201, 5, 239,
+			2, 166, 11, 188, 57, 161, 96, 32, 161, 10, 143, 211, 249, 193,
+			1, 248, 195, 248, 58, 37, 76, 115, 57, 126, 129, 22, 20, 36,
+			146, 176, 174, 60, 14, 132, 19, 234, 223, 229, 105, 232, 243, 25,
+			242, 166, 162, 93, 218, 197, 64, 86, 83, 23, 199, 225, 12, 167,
+			162, 93, 172, 75, 39, 157, 141, 225, 44, 6, 130, 97, 254, 19,
+			100, 246, 73, 50, 195, 200, 254, 179, 146, 68, 140, 164, 207, 74,
+			210, 100, 228, 243, 55, 240, 183, 33, 129, 7, 108, 253, 48, 50,
+			243, 246, 251, 248, 32, 10, 141, 33, 7, 68, 233, 118, 177, 206,
+			203, 167, 142, 230, 33, 10, 91, 172, 236, 250, 101, 152, 14, 56,
+			70, 213, 218, 101, 103, 109, 205, 221, 106, 239, 236, 0, 114, 68,
+			59, 104, 249, 161, 171, 163, 45, 176, 70, 101, 160, 85, 195, 146,
+			132, 70, 30, 31, 151, 164, 201, 200, 75, 151, 241, 175, 112, 52,
+			222, 204, 103, 81, 234, 103, 16, 178, 255, 213, 193, 46, 116, 111,
+			119, 82, 239, 11, 205, 141, 21, 117, 248, 176, 247, 93, 222, 116,
+			25, 187, 74, 95, 46, 172, 204, 3, 6, 119, 121, 44, 16, 205,
+			224, 18, 113, 240, 6, 46, 206, 88, 241, 90, 32, 188, 65, 66,
+			238, 20, 223, 199, 113, 121, 173, 207, 162, 236, 9, 124, 67, 224,
+			242, 90, 63, 134, 172, 115, 118, 94, 239, 227, 161, 179, 4, 157,
+			18, 195, 105, 192, 30, 255, 49, 100, 245, 74, 18, 192, 10, 240,
+			41, 73, 154, 140, 60, 75, 241, 79, 115, 4, 96, 68, 172, 159,
+			64, 214, 140, 253, 63, 162, 206, 170, 14, 85, 199, 240, 234, 98,
+			116, 191, 93, 151, 174, 181, 156, 102, 211, 13, 120, 56, 123, 79,
+			135, 254, 150, 193, 89, 32, 228, 38, 59, 72, 248, 195, 176, 112,
+			157, 247, 154, 85, 63, 104, 249, 28, 222, 71, 74, 160, 114, 16,
+			85, 93, 157, 110, 35, 241, 227, 42, 239, 19, 91, 64, 63, 129,
+			172, 211, 146, 132, 62, 157, 185, 44, 73, 147, 145, 133, 105, 252,
+			62, 222, 99, 131, 88, 63, 141, 172, 89, 123, 175, 179, 195, 221,
+			31, 10, 14, 118, 86, 117, 14, 39, 48, 35, 58, 30, 50, 64,
+			205, 35, 148, 86, 241, 122, 83, 109, 102, 162, 221, 79, 199, 109,
+			102, 243, 240, 211, 232, 76, 94, 146, 38, 35, 167, 103, 64, 100,
+			49, 73, 230, 103, 81, 234, 223, 9, 145, 197, 68, 196, 250, 89,
+			148, 61, 139, 235, 2, 237, 216, 250, 215, 200, 26, 177, 223, 25,
+			75, 124, 178, 65, 240, 210, 207, 207, 194, 88, 31, 31, 114, 237,
+			91, 20, 120, 238, 158, 155, 240, 80, 23, 32, 3, 55, 148, 72,
+			60, 173, 0, 38, 69, 171, 1, 14, 153, 85, 167, 72, 196, 200,
+			190, 33, 73, 154, 140, 28, 62, 142, 255, 3, 18, 144, 200, 214,
+			47, 32, 235, 140, 253, 111, 144, 194, 91, 60, 12, 20, 48, 212,
+			219, 116, 120, 67, 148, 215, 205, 244, 235, 210, 190, 83, 109, 131,
+			247, 198, 105, 128, 109, 73, 23, 33, 96, 161, 88, 62, 62, 220,
+			173, 224, 8, 213, 181, 167, 177, 48, 154, 103, 210, 168, 106, 205,
+			129, 235, 23, 119, 192, 242, 20, 138, 133, 9, 103, 218, 47, 72,
+			25, 20, 224, 157, 173, 95, 64, 3, 199, 36, 9, 157, 31, 62,
+			33, 73, 147, 145, 167, 78, 195, 132, 90, 36, 243, 139, 40, 245,
+			203, 98, 66, 45, 68, 172, 95, 100, 66, 254, 184, 64, 104, 182,
+			126, 9, 89, 57, 129, 162, 161, 13, 143, 104, 137, 168, 223, 130,
+			131, 224, 151, 100, 253, 22, 28, 4, 191, 132, 6, 134, 37, 137,
+			24, 121, 252, 180, 36, 77, 70, 210, 115, 80, 127, 154, 100, 254,
+			61, 74, 189, 207, 224, 245, 167, 17, 177, 254, 61, 202, 30, 133,
+			168, 214, 105, 86, 255, 23, 144, 53, 108, 191, 235, 137, 115, 246,
+			53, 155, 41, 104, 35, 0, 229, 178, 170, 21, 137, 24, 217, 119,
+			84, 146, 38, 35, 201, 49, 92, 20, 80, 185, 214, 23, 145, 117,
+			194, 190, 66, 95, 137, 209, 7, 184, 134, 217, 129, 192, 122, 205,
+			72, 185, 224, 137, 147, 52, 214, 144, 12, 72, 156, 90, 86, 70,
+			86, 146, 80, 100, 239, 176, 36, 77, 70, 142, 142, 225, 255, 158,
+			99, 243, 26, 196, 250, 45, 100, 29, 183, 191, 5, 61, 1, 250,
+			52, 30, 29, 128, 2, 72, 74, 170, 7, 20, 217, 82, 57, 169,
+			185, 73, 208, 91, 52, 10, 218, 108, 35, 44, 251, 81, 199, 247,
+			140, 41, 38, 141, 24, 66, 213, 31, 35, 13, 77, 84, 36, 98,
+			100, 223, 160, 36, 77, 70, 30, 27, 134, 48, 224, 105, 118, 49,
+			249, 79, 200, 250, 63, 81, 154, 255, 12, 215, 139, 255, 132, 250,
+			199, 240, 32, 206, 48, 146, 101, 248, 29, 148, 253, 61, 212, 11,
+			18, 89, 90, 220, 64, 126, 7, 13, 142, 224, 59, 56, 203, 19,
+			216, 98, 249, 223, 81, 255, 25, 251, 26, 231, 165, 240, 174, 174,
+			252, 246, 162, 192, 171, 210, 137, 176, 221, 104, 72, 229, 7, 104,
+			231, 132, 124, 61, 41, 228, 106, 81, 80, 26, 74, 26, 210, 147,
+			16, 75, 34, 39, 244, 36, 147, 37, 157, 58, 141, 175, 242, 70,
+			178, 250, 255, 16, 101, 79, 218, 23, 160, 126, 81, 37, 111, 134,
+			156, 14, 56, 121, 106, 206, 190, 144, 70, 211, 226, 242, 242, 135,
+			82, 10, 78, 139, 170, 254, 80, 222, 226, 211, 162, 162, 63, 100,
+			183, 248, 155, 162, 34, 68, 172, 63, 66, 217, 211, 246, 229, 39,
+			84, 196, 13, 120, 106, 206, 126, 168, 213, 199, 206, 167, 63, 210,
+			235, 67, 80, 220, 209, 177, 56, 193, 100, 9, 39, 79, 225, 107,
+			162, 62, 131, 88, 95, 98, 245, 93, 124, 66, 125, 15, 93, 247,
+			129, 86, 19, 59, 85, 190, 164, 215, 100, 0, 68, 130, 86, 19,
+			91, 6, 95, 98, 53, 45, 193, 170, 54, 137, 245, 101, 100, 77,
+			219, 47, 242, 43, 187, 31, 57, 117, 137, 46, 226, 111, 115, 148,
+			114, 205, 158, 86, 220, 155, 132, 11, 170, 50, 92, 85, 43, 208,
+			204, 64, 113, 114, 201, 177, 163, 234, 203, 104, 232, 146, 36, 161,
+			178, 169, 2, 254, 28, 223, 81, 22, 177, 254, 12, 89, 115, 246,
+			63, 71, 93, 43, 239, 84, 115, 198, 18, 128, 19, 41, 0, 76,
+			120, 1, 9, 246, 223, 229, 111, 133, 34, 196, 57, 96, 160, 197,
+			64, 87, 170, 145, 83, 96, 112, 236, 53, 119, 56, 202, 43, 100,
+			139, 109, 237, 57, 220, 14, 86, 122, 153, 56, 86, 156, 30, 187,
+			135, 250, 1, 237, 136, 220, 163, 250, 110, 101, 160, 59, 178, 239,
+			140, 171, 255, 25, 26, 154, 146, 164, 201, 200, 153, 89, 8, 151,
+			146, 102, 59, 245, 47, 216, 201, 88, 120, 154, 158, 171, 170, 210,
+			25, 248, 92, 86, 197, 24, 248, 95, 160, 161, 19, 146, 52, 25,
+			121, 234, 52, 254, 128, 9, 117, 101, 136, 245, 13, 134, 117, 197,
+			254, 75, 35, 161, 129, 58, 96, 14, 23, 251, 17, 117, 188, 76,
+			21, 232, 170, 226, 65, 252, 246, 253, 215, 197, 201, 132, 201, 129,
+			208, 27, 234, 54, 95, 7, 90, 43, 224, 145, 14, 20, 146, 7,
+			115, 5, 221, 14, 24, 76, 172, 238, 184, 220, 210, 65, 74, 25,
+			177, 65, 14, 92, 149, 203, 139, 180, 225, 68, 213, 221, 142, 215,
+			75, 208, 122, 208, 13, 33, 130, 110, 59, 85, 175, 238, 65, 80,
+			97, 128, 5, 227, 38, 12, 77, 247, 33, 47, 72, 25, 156, 56,
+			7, 199, 70, 77, 93, 38, 13, 179, 161, 72, 196, 200, 190, 130,
+			36, 77, 70, 206, 206, 225, 107, 0, 143, 158, 249, 123, 70, 234,
+			3, 6, 178, 199, 187, 138, 50, 220, 66, 82, 162, 86, 192, 83,
+			112, 31, 135, 70, 183, 254, 158, 145, 125, 6, 191, 87, 96, 163,
+			91, 223, 108, 88, 195, 182, 15, 147, 175, 159, 226, 129, 102, 32,
+			173, 63, 253, 138, 3, 63, 41, 38, 62, 230, 160, 15, 186, 24,
+			90, 139, 254, 2, 98, 58, 171, 95, 145, 136, 145, 226, 76, 7,
+			204, 116, 235, 155, 13, 114, 12, 127, 26, 1, 198, 117, 230, 35,
+			70, 234, 147, 6, 178, 191, 3, 209, 110, 230, 219, 177, 194, 94,
+			200, 221, 221, 94, 171, 29, 44, 238, 108, 186, 44, 60, 30, 38,
+			21, 141, 252, 89, 85, 189, 181, 193, 85, 3, 107, 119, 73, 221,
+			188, 73, 37, 139, 1, 238, 65, 196, 250, 136, 145, 61, 133, 223,
+			45, 208, 157, 173, 111, 99, 3, 92, 125, 202, 1, 254, 170, 7,
+			21, 0, 159, 89, 157, 138, 68, 140, 20, 131, 10, 144, 207, 214,
+			183, 177, 65, 253, 25, 137, 249, 108, 125, 204, 176, 206, 217, 63,
+			138, 232, 106, 135, 27, 154, 188, 209, 168, 198, 73, 1, 79, 24,
+			4, 36, 71, 54, 104, 179, 107, 110, 195, 117, 194, 118, 32, 175,
+			107, 126, 59, 116, 154, 181, 104, 55, 164, 19, 45, 55, 160, 224,
+			244, 54, 9, 70, 164, 252, 233, 71, 139, 58, 0, 33, 159, 66,
+			204, 31, 76, 103, 232, 196, 204, 69, 42, 241, 143, 39, 249, 43,
+			222, 204, 12, 157, 152, 157, 209, 211, 85, 151, 153, 172, 246, 49,
+			67, 92, 106, 1, 225, 217, 250, 152, 33, 46, 181, 0, 241, 108,
+			125, 204, 56, 75, 65, 54, 236, 97, 135, 231, 119, 25, 214, 89,
+			161, 178, 218, 241, 157, 122, 226, 26, 42, 75, 175, 29, 236, 159,
+			170, 143, 113, 205, 239, 50, 172, 227, 146, 68, 140, 28, 177, 37,
+			105, 50, 242, 244, 25, 252, 199, 124, 136, 77, 98, 125, 130, 85,
+			248, 235, 232, 64, 141, 135, 14, 101, 129, 163, 147, 194, 137, 138,
+			187, 206, 76, 140, 175, 54, 201, 239, 159, 226, 82, 162, 148, 79,
+			50, 152, 200, 147, 122, 197, 149, 190, 123, 142, 87, 7, 105, 245,
+			128, 221, 106, 152, 199, 135, 234, 75, 188, 144, 182, 155, 34, 38,
+			172, 26, 29, 118, 206, 127, 34, 30, 29, 118, 206, 127, 34, 30,
+			29, 19, 134, 227, 244, 25, 184, 107, 100, 73, 230, 83, 70, 234,
+			87, 196, 93, 35, 139, 136, 245, 41, 35, 123, 1, 159, 147, 136,
+			129, 159, 49, 172, 81, 129, 139, 172, 63, 87, 36, 176, 2, 63,
+			35, 215, 58, 199, 10, 252, 140, 33, 30, 32, 56, 86, 224, 103,
+			140, 227, 35, 248, 47, 123, 36, 88, 224, 231, 12, 235, 140, 253,
+			71, 61, 180, 216, 164, 197, 242, 234, 212, 236, 179, 51, 148, 123,
+			25, 8, 83, 2, 205, 66, 44, 97, 155, 145, 12, 125, 146, 208,
+			11, 193, 168, 8, 187, 45, 41, 140, 129, 113, 67, 1, 99, 202,
+			61, 33, 129, 157, 180, 91, 45, 63, 128, 160, 80, 177, 185, 195,
+			219, 101, 35, 182, 101, 190, 119, 76, 28, 134, 60, 244, 236, 12,
+			183, 35, 140, 113, 146, 156, 128, 155, 222, 77, 133, 110, 51, 244,
+			34, 111, 143, 191, 146, 211, 45, 21, 230, 10, 22, 138, 235, 4,
+			213, 93, 113, 174, 74, 153, 193, 175, 183, 27, 77, 206, 246, 54,
+			189, 26, 183, 144, 233, 192, 124, 224, 145, 14, 224, 153, 11, 172,
+			199, 53, 124, 145, 28, 171, 231, 85, 191, 13, 38, 41, 236, 68,
+			45, 46, 47, 230, 233, 74, 5, 198, 98, 121, 101, 157, 78, 128,
+			69, 160, 106, 214, 36, 173, 251, 59, 32, 113, 249, 45, 38, 81,
+			1, 164, 171, 83, 247, 155, 59, 152, 31, 84, 224, 242, 224, 53,
+			119, 10, 116, 124, 106, 28, 160, 66, 18, 226, 198, 242, 202, 122,
+			129, 222, 147, 96, 38, 91, 90, 20, 47, 240, 86, 0, 43, 121,
+			30, 45, 152, 63, 39, 22, 151, 23, 105, 232, 182, 28, 1, 18,
+			205, 123, 178, 14, 86, 24, 236, 219, 184, 240, 27, 152, 238, 186,
+			245, 186, 79, 31, 250, 65, 189, 6, 176, 228, 42, 137, 21, 194,
+			147, 49, 189, 231, 131, 60, 200, 95, 217, 111, 96, 234, 176, 206,
+			110, 177, 127, 89, 174, 229, 149, 245, 137, 45, 54, 70, 83, 213,
+			73, 109, 96, 196, 146, 2, 215, 65, 182, 58, 2, 53, 236, 208,
+			231, 241, 91, 227, 121, 58, 126, 238, 214, 56, 183, 73, 191, 49,
+			78, 39, 118, 157, 112, 50, 30, 34, 105, 176, 3, 158, 54, 187,
+			96, 102, 226, 213, 212, 249, 33, 179, 41, 232, 19, 135, 134, 240,
+			16, 47, 49, 242, 74, 133, 157, 27, 88, 206, 240, 141, 8, 2,
+			34, 71, 193, 62, 166, 83, 29, 54, 50, 235, 94, 195, 245, 219,
+			16, 29, 185, 155, 7, 223, 173, 28, 56, 250, 77, 93, 191, 62,
+			55, 247, 252, 179, 87, 174, 205, 204, 92, 185, 126, 253, 217, 231,
+			174, 95, 155, 189, 146, 195, 152, 174, 241, 117, 205, 164, 25, 185,
+			168, 124, 177, 230, 168, 223, 4, 23, 5, 209, 6, 246, 103, 178,
+			106, 150, 18, 184, 78, 189, 193, 254, 232, 94, 59, 251, 229, 128,
+			138, 64, 79, 228, 89, 196, 35, 249, 230, 174, 19, 238, 170, 42,
+			185, 151, 157, 2, 242, 76, 195, 246, 199, 26, 144, 231, 231, 140,
+			190, 19, 26, 144, 231, 231, 140, 83, 167, 241, 87, 12, 224, 21,
+			6, 177, 126, 217, 176, 198, 236, 223, 51, 104, 17, 244, 150, 206,
+			84, 232, 182, 196, 117, 128, 93, 119, 64, 20, 231, 47, 251, 0,
+			55, 83, 139, 213, 143, 240, 204, 66, 183, 246, 11, 152, 222, 135,
+			24, 65, 34, 31, 91, 123, 185, 3, 14, 192, 185, 60, 205, 61,
+			206, 209, 55, 199, 209, 242, 115, 202, 71, 86, 172, 11, 25, 234,
+			57, 244, 3, 238, 32, 2, 77, 128, 96, 29, 85, 14, 187, 6,
+			175, 248, 252, 156, 216, 7, 244, 63, 129, 198, 6, 57, 177, 16,
+			146, 69, 28, 241, 86, 203, 109, 214, 168, 67, 115, 144, 49, 199,
+			109, 26, 30, 169, 120, 39, 7, 90, 13, 217, 242, 49, 131, 132,
+			175, 4, 118, 90, 164, 2, 104, 111, 237, 211, 208, 173, 114, 99,
+			176, 166, 38, 142, 206, 65, 229, 28, 197, 24, 162, 163, 132, 138,
+			165, 179, 219, 207, 47, 199, 211, 196, 152, 246, 47, 27, 125, 199,
+			36, 105, 50, 114, 100, 20, 78, 143, 94, 146, 249, 85, 35, 245,
+			107, 226, 244, 232, 69, 196, 250, 85, 35, 123, 17, 2, 189, 1,
+			148, 234, 23, 12, 107, 206, 190, 149, 212, 148, 129, 149, 57, 183,
+			205, 82, 172, 90, 70, 49, 233, 102, 222, 38, 192, 73, 45, 40,
+			77, 66, 99, 166, 50, 140, 28, 56, 169, 33, 153, 126, 193, 56,
+			53, 165, 33, 153, 126, 193, 152, 153, 133, 54, 98, 146, 249, 162,
+			145, 250, 51, 209, 70, 140, 136, 245, 69, 35, 59, 130, 167, 177,
+			101, 97, 214, 198, 95, 55, 172, 9, 251, 156, 222, 70, 229, 19,
+			224, 133, 29, 23, 4, 12, 21, 255, 186, 97, 141, 73, 18, 49,
+			242, 196, 121, 73, 154, 140, 124, 102, 28, 222, 171, 49, 104, 120,
+			12, 235, 184, 61, 75, 215, 189, 168, 46, 148, 95, 126, 211, 157,
+			2, 148, 80, 142, 6, 217, 210, 13, 245, 58, 107, 99, 59, 230,
+			183, 228, 84, 96, 216, 49, 191, 101, 8, 149, 17, 134, 29, 243,
+			91, 198, 177, 97, 220, 128, 218, 12, 98, 253, 174, 97, 93, 180,
+			55, 223, 200, 195, 173, 80, 153, 131, 143, 132, 68, 212, 18, 78,
+			45, 220, 64, 171, 211, 135, 166, 67, 157, 133, 65, 4, 251, 93,
+			195, 58, 37, 73, 196, 200, 211, 84, 146, 38, 35, 207, 95, 192,
+			223, 130, 160, 113, 38, 177, 254, 192, 176, 168, 12, 101, 24, 95,
+			166, 107, 194, 73, 154, 214, 220, 61, 183, 238, 183, 58, 52, 26,
+			160, 51, 16, 10, 13, 7, 204, 189, 217, 198, 111, 186, 88, 67,
+			49, 156, 88, 120, 121, 18, 228, 40, 9, 206, 29, 223, 209, 39,
+			194, 73, 126, 226, 118, 153, 74, 51, 13, 109, 234, 149, 36, 98,
+			36, 62, 41, 73, 104, 241, 153, 179, 248, 71, 77, 232, 128, 69,
+			172, 63, 53, 172, 203, 246, 167, 205, 142, 14, 168, 13, 40, 157,
+			245, 4, 39, 148, 218, 24, 174, 240, 136, 93, 25, 68, 80, 183,
+			131, 138, 143, 188, 254, 129, 166, 250, 224, 16, 251, 34, 116, 2,
+			117, 170, 17, 135, 190, 134, 207, 180, 114, 38, 85, 247, 59, 66,
+			77, 40, 40, 114, 13, 90, 77, 154, 52, 137, 198, 200, 32, 35,
+			62, 147, 191, 124, 192, 218, 7, 115, 186, 200, 107, 77, 249, 219,
+			83, 81, 224, 114, 127, 3, 24, 71, 209, 117, 176, 26, 107, 78,
+			189, 199, 13, 252, 60, 245, 34, 105, 101, 236, 36, 43, 16, 93,
+			114, 194, 3, 67, 208, 101, 4, 20, 146, 248, 1, 132, 244, 154,
+			79, 67, 63, 175, 35, 188, 109, 49, 110, 241, 136, 221, 232, 3,
+			14, 158, 206, 26, 35, 11, 82, 115, 108, 165, 97, 218, 228, 28,
+			91, 136, 145, 248, 25, 73, 154, 140, 156, 188, 132, 191, 159, 47,
+			210, 52, 177, 190, 194, 228, 221, 143, 117, 215, 120, 117, 198, 90,
+			77, 172, 170, 216, 140, 15, 84, 48, 241, 115, 142, 254, 145, 176,
+			107, 8, 244, 48, 48, 208, 41, 112, 147, 116, 52, 148, 206, 106,
+			224, 132, 187, 121, 76, 157, 45, 63, 136, 164, 231, 167, 234, 86,
+			154, 183, 84, 118, 43, 141, 24, 137, 137, 36, 77, 70, 66, 208,
+			45, 195, 234, 35, 153, 63, 55, 82, 255, 69, 176, 191, 62, 68,
+			172, 63, 55, 178, 231, 241, 119, 177, 30, 247, 49, 254, 247, 87,
+			134, 53, 98, 127, 240, 201, 79, 64, 9, 245, 140, 90, 244, 32,
+			101, 127, 77, 222, 25, 166, 59, 120, 126, 31, 220, 45, 254, 74,
+			114, 191, 62, 224, 181, 127, 101, 136, 215, 172, 62, 224, 181, 127,
+			101, 12, 31, 135, 94, 246, 147, 204, 215, 153, 169, 247, 153, 188,
+			151, 253, 136, 88, 95, 103, 102, 47, 224, 183, 99, 203, 234, 103,
+			157, 252, 6, 211, 154, 181, 239, 73, 164, 233, 195, 53, 120, 116,
+			201, 107, 120, 194, 38, 141, 99, 244, 194, 236, 193, 115, 197, 28,
+			187, 0, 75, 17, 84, 52, 178, 31, 14, 166, 111, 48, 197, 193,
+			212, 15, 231, 195, 55, 152, 3, 231, 37, 137, 24, 121, 33, 47,
+			73, 147, 145, 211, 51, 248, 19, 8, 27, 214, 0, 201, 124, 147,
+			153, 250, 109, 19, 217, 223, 138, 104, 119, 176, 136, 100, 44, 219,
+			46, 76, 135, 175, 40, 88, 62, 220, 178, 168, 234, 212, 177, 244,
+			63, 242, 220, 80, 240, 249, 150, 116, 155, 14, 235, 76, 152, 173,
+			239, 195, 125, 148, 221, 104, 89, 95, 3, 167, 25, 110, 243, 130,
+			181, 254, 177, 145, 28, 64, 196, 250, 38, 51, 123, 6, 255, 83,
+			182, 94, 6, 204, 20, 201, 124, 208, 180, 62, 108, 166, 237, 79,
+			35, 90, 145, 109, 83, 198, 230, 201, 192, 0, 5, 142, 179, 24,
+			7, 10, 136, 3, 196, 200, 128, 11, 190, 22, 28, 65, 56, 229,
+			56, 16, 127, 129, 29, 149, 91, 34, 254, 27, 136, 7, 91, 117,
+			253, 17, 231, 96, 216, 8, 156, 48, 214, 147, 129, 113, 249, 44,
+			13, 192, 219, 200, 7, 205, 254, 81, 60, 143, 51, 140, 100, 107,
+			226, 67, 102, 118, 70, 68, 236, 105, 56, 213, 93, 175, 233, 198,
+			47, 70, 66, 207, 44, 171, 211, 59, 197, 181, 249, 3, 226, 157,
+			226, 67, 108, 153, 169, 4, 196, 18, 46, 94, 142, 19, 76, 150,
+			80, 152, 198, 119, 96, 244, 16, 201, 124, 171, 105, 253, 35, 51,
+			109, 63, 215, 109, 240, 58, 227, 109, 194, 112, 72, 224, 231, 74,
+			187, 57, 169, 186, 3, 118, 113, 102, 255, 24, 254, 21, 19, 250,
+			195, 99, 197, 152, 217, 231, 236, 159, 53, 31, 31, 155, 82, 56,
+			241, 75, 219, 155, 56, 210, 170, 140, 148, 173, 131, 109, 198, 134,
+			161, 96, 101, 157, 91, 120, 249, 242, 108, 110, 154, 253, 51, 151,
+			163, 2, 9, 154, 29, 123, 59, 110, 16, 192, 51, 3, 93, 105,
+			138, 250, 26, 91, 162, 190, 123, 197, 87, 249, 242, 4, 99, 98,
+			245, 228, 30, 95, 49, 104, 121, 49, 212, 252, 177, 60, 54, 149,
+			108, 250, 252, 118, 40, 159, 14, 38, 185, 243, 157, 187, 231, 6,
+			135, 21, 193, 133, 24, 150, 3, 124, 184, 59, 36, 4, 214, 144,
+			242, 162, 110, 194, 9, 183, 248, 4, 111, 150, 115, 13, 170, 155,
+			228, 167, 32, 37, 65, 39, 120, 185, 9, 240, 26, 86, 88, 121,
+			251, 128, 193, 21, 59, 242, 184, 85, 181, 140, 10, 226, 64, 92,
+			186, 168, 203, 84, 131, 117, 138, 112, 61, 104, 134, 110, 164, 214,
+			24, 183, 31, 250, 184, 153, 61, 23, 39, 32, 150, 144, 155, 139,
+			19, 76, 150, 112, 237, 89, 252, 31, 13, 177, 22, 16, 177, 190,
+			223, 204, 158, 176, 255, 13, 127, 81, 0, 187, 242, 238, 139, 33,
+			30, 144, 132, 5, 69, 226, 139, 133, 37, 142, 33, 207, 248, 12,
+			91, 0, 211, 108, 250, 249, 137, 94, 247, 170, 15, 220, 26, 166,
+			19, 238, 158, 11, 16, 169, 160, 116, 0, 83, 141, 228, 240, 69,
+			187, 49, 18, 234, 194, 82, 200, 235, 19, 207, 193, 97, 213, 111,
+			201, 141, 173, 176, 74, 65, 66, 168, 250, 13, 23, 88, 74, 195,
+			241, 234, 212, 169, 213, 64, 7, 234, 177, 226, 189, 61, 167, 186,
+			143, 217, 165, 41, 240, 247, 156, 58, 24, 30, 110, 113, 235, 182,
+			60, 109, 55, 35, 175, 206, 218, 222, 20, 193, 2, 115, 50, 50,
+			137, 223, 204, 209, 9, 184, 117, 169, 4, 172, 44, 163, 156, 42,
+			192, 207, 132, 252, 1, 42, 215, 14, 221, 32, 167, 163, 82, 171,
+			17, 103, 82, 249, 247, 155, 226, 21, 15, 18, 96, 196, 143, 14,
+			199, 9, 38, 75, 24, 29, 195, 175, 136, 41, 49, 136, 245, 3,
+			102, 118, 202, 190, 195, 217, 141, 31, 107, 17, 58, 176, 223, 225,
+			178, 39, 224, 212, 243, 244, 246, 198, 210, 18, 255, 43, 129, 179,
+			174, 181, 133, 73, 225, 63, 96, 102, 207, 199, 9, 136, 37, 92,
+			152, 136, 19, 76, 150, 112, 57, 143, 39, 25, 15, 98, 140, 226,
+			7, 77, 107, 212, 62, 9, 45, 233, 140, 106, 26, 197, 166, 108,
+			3, 112, 254, 254, 160, 105, 41, 18, 49, 82, 232, 246, 6, 96,
+			221, 253, 160, 121, 124, 4, 116, 133, 3, 96, 186, 102, 90, 231,
+			133, 174, 176, 27, 19, 230, 134, 100, 166, 53, 34, 73, 248, 100,
+			244, 140, 36, 77, 70, 158, 203, 225, 127, 11, 71, 13, 27, 178,
+			207, 178, 83, 251, 39, 17, 85, 48, 80, 241, 113, 40, 95, 130,
+			120, 48, 9, 209, 122, 240, 87, 10, 220, 72, 184, 13, 181, 220,
+			192, 243, 107, 114, 99, 227, 238, 242, 27, 40, 190, 196, 133, 94,
+			109, 108, 176, 36, 225, 158, 64, 109, 25, 24, 181, 99, 81, 243,
+			138, 33, 159, 220, 69, 148, 7, 118, 133, 8, 116, 234, 87, 213,
+			123, 54, 83, 159, 53, 133, 69, 209, 0, 204, 211, 103, 77, 97,
+			81, 52, 0, 179, 244, 89, 38, 25, 172, 67, 231, 77, 98, 253,
+			51, 54, 154, 183, 233, 34, 191, 157, 203, 32, 133, 201, 70, 116,
+			185, 224, 133, 202, 254, 74, 103, 119, 188, 18, 51, 3, 197, 14,
+			74, 18, 49, 114, 72, 78, 128, 9, 149, 158, 203, 225, 239, 230,
+			19, 96, 17, 235, 39, 77, 235, 172, 253, 223, 33, 221, 134, 67,
+			143, 62, 167, 133, 82, 234, 60, 188, 4, 8, 57, 15, 161, 84,
+			222, 238, 248, 146, 49, 198, 195, 120, 161, 206, 6, 36, 210, 193,
+			150, 43, 195, 200, 169, 190, 48, 201, 254, 39, 77, 97, 29, 50,
+			0, 146, 253, 79, 154, 189, 182, 36, 77, 70, 158, 62, 131, 75,
+			208, 149, 52, 177, 126, 218, 180, 46, 217, 207, 113, 20, 106, 13,
+			115, 75, 134, 35, 75, 132, 131, 146, 151, 23, 17, 25, 66, 213,
+			153, 182, 160, 156, 126, 73, 102, 24, 57, 48, 38, 73, 196, 200,
+			19, 23, 37, 105, 50, 114, 98, 18, 255, 134, 1, 109, 200, 16,
+			235, 95, 155, 86, 222, 254, 60, 231, 202, 177, 168, 31, 15, 141,
+			126, 54, 195, 109, 173, 227, 112, 166, 11, 113, 252, 74, 208, 128,
+			11, 5, 167, 22, 169, 82, 205, 61, 28, 168, 252, 197, 183, 51,
+			94, 96, 157, 223, 154, 118, 157, 218, 193, 80, 150, 147, 188, 18,
+			9, 35, 35, 205, 251, 68, 69, 80, 146, 208, 158, 198, 149, 226,
+			228, 136, 138, 96, 153, 201, 72, 140, 112, 128, 107, 65, 15, 32,
+			120, 141, 186, 227, 118, 30, 64, 252, 227, 3, 113, 225, 187, 43,
+			40, 213, 244, 100, 248, 16, 203, 53, 144, 65, 140, 60, 57, 46,
+			73, 147, 145, 151, 46, 227, 31, 229, 243, 209, 67, 172, 95, 52,
+			173, 103, 236, 79, 243, 249, 208, 164, 9, 144, 55, 165, 47, 70,
+			114, 10, 18, 118, 131, 202, 236, 80, 200, 176, 126, 107, 170, 238,
+			238, 185, 117, 189, 44, 21, 227, 92, 126, 196, 46, 177, 52, 23,
+			231, 200, 241, 232, 97, 28, 241, 135, 207, 252, 193, 203, 102, 34,
+			0, 190, 52, 71, 151, 15, 193, 113, 70, 28, 71, 225, 6, 243,
+			88, 233, 85, 147, 87, 151, 83, 222, 138, 80, 107, 97, 120, 96,
+			152, 5, 15, 219, 106, 87, 31, 184, 145, 152, 114, 53, 198, 61,
+			105, 24, 54, 69, 34, 70, 246, 157, 147, 164, 201, 200, 11, 23,
+			241, 31, 241, 49, 206, 18, 235, 215, 216, 154, 255, 143, 6, 45,
+			135, 154, 143, 126, 82, 122, 19, 80, 115, 188, 95, 114, 188, 197,
+			30, 124, 83, 60, 198, 220, 59, 54, 104, 187, 224, 115, 80, 175,
+			211, 9, 118, 229, 15, 31, 120, 173, 150, 91, 155, 236, 38, 204,
+			117, 148, 21, 71, 54, 238, 210, 138, 201, 206, 107, 185, 238, 97,
+			171, 161, 204, 197, 208, 3, 60, 52, 30, 245, 154, 210, 59, 28,
+			212, 254, 55, 105, 117, 215, 173, 62, 72, 108, 9, 204, 53, 242,
+			174, 100, 226, 58, 22, 149, 23, 118, 31, 19, 8, 41, 39, 71,
+			6, 162, 110, 54, 60, 240, 66, 229, 3, 149, 184, 102, 201, 217,
+			200, 166, 97, 188, 37, 79, 204, 34, 70, 246, 202, 13, 144, 53,
+			25, 121, 233, 50, 126, 23, 204, 77, 47, 177, 126, 221, 180, 174,
+			218, 127, 71, 120, 167, 116, 70, 188, 209, 244, 111, 160, 145, 18,
+			193, 53, 53, 145, 113, 67, 122, 119, 234, 31, 240, 39, 49, 176,
+			3, 136, 119, 102, 175, 5, 149, 73, 198, 217, 155, 97, 228, 64,
+			78, 146, 136, 145, 231, 11, 146, 52, 25, 57, 123, 133, 95, 154,
+			12, 76, 172, 223, 50, 173, 227, 246, 243, 29, 122, 183, 46, 138,
+			21, 9, 54, 21, 186, 250, 13, 88, 54, 2, 167, 161, 164, 94,
+			73, 34, 70, 226, 163, 146, 52, 25, 73, 134, 21, 204, 252, 175,
+			13, 226, 153, 39, 162, 180, 123, 77, 143, 135, 162, 234, 18, 139,
+			226, 5, 60, 122, 199, 229, 12, 174, 220, 244, 162, 69, 39, 114,
+			36, 184, 231, 57, 220, 31, 184, 219, 110, 16, 184, 193, 102, 59,
+			168, 11, 56, 205, 62, 153, 182, 17, 212, 115, 247, 240, 216, 193,
+			175, 5, 198, 231, 44, 238, 85, 21, 11, 8, 195, 97, 29, 134,
+			78, 125, 144, 245, 196, 95, 185, 143, 33, 156, 149, 201, 228, 10,
+			238, 221, 245, 57, 38, 84, 216, 13, 125, 245, 174, 252, 177, 18,
+			231, 35, 23, 176, 197, 68, 98, 129, 240, 57, 168, 231, 223, 8,
+			221, 160, 2, 191, 178, 166, 57, 237, 104, 151, 245, 42, 20, 176,
+			158, 137, 166, 21, 219, 209, 238, 70, 80, 15, 43, 89, 71, 252,
+			149, 187, 142, 123, 85, 133, 228, 50, 30, 146, 241, 244, 55, 101,
+			221, 98, 120, 6, 229, 15, 50, 119, 238, 20, 182, 88, 213, 100,
+			24, 167, 225, 142, 32, 50, 114, 34, 55, 137, 179, 178, 54, 114,
+			26, 227, 186, 191, 227, 183, 35, 109, 184, 123, 121, 202, 70, 80,
+			159, 139, 240, 144, 28, 28, 49, 232, 126, 64, 54, 241, 96, 231,
+			12, 36, 97, 21, 15, 153, 221, 36, 172, 226, 97, 147, 248, 212,
+			112, 128, 63, 211, 135, 51, 196, 74, 165, 174, 34, 156, 5, 44,
+			192, 84, 138, 8, 164, 61, 67, 33, 237, 89, 49, 210, 30, 251,
+			243, 173, 28, 60, 175, 39, 213, 135, 236, 37, 90, 212, 156, 64,
+			156, 40, 214, 9, 137, 80, 237, 112, 192, 122, 145, 231, 212, 189,
+			247, 8, 86, 199, 229, 105, 110, 129, 158, 0, 100, 82, 96, 72,
+			61, 217, 19, 184, 42, 177, 144, 176, 177, 97, 223, 167, 178, 199,
+			252, 169, 71, 66, 26, 177, 162, 161, 192, 195, 45, 191, 15, 123,
+			119, 191, 242, 108, 2, 136, 5, 103, 70, 52, 32, 22, 60, 90,
+			208, 128, 88, 240, 243, 47, 227, 55, 243, 200, 37, 71, 82, 4,
+			217, 87, 105, 81, 1, 208, 68, 62, 221, 113, 5, 200, 150, 27,
+			236, 185, 193, 120, 216, 173, 195, 90, 28, 147, 35, 217, 179, 16,
+			229, 16, 58, 55, 100, 157, 178, 215, 128, 7, 109, 84, 150, 212,
+			237, 205, 217, 81, 86, 232, 242, 74, 194, 35, 63, 70, 49, 150,
+			64, 141, 87, 205, 71, 133, 219, 185, 192, 202, 99, 5, 37, 34,
+			96, 12, 89, 88, 139, 128, 49, 212, 55, 170, 69, 192, 24, 178,
+			79, 226, 5, 174, 193, 62, 158, 26, 69, 246, 115, 208, 51, 241,
+			210, 233, 111, 73, 23, 7, 38, 64, 200, 134, 28, 222, 57, 132,
+			136, 121, 60, 75, 113, 159, 112, 221, 50, 71, 172, 147, 80, 23,
+			40, 28, 204, 17, 171, 95, 82, 136, 152, 35, 3, 35, 146, 50,
+			137, 57, 114, 194, 198, 31, 231, 174, 62, 214, 201, 212, 89, 208,
+			93, 174, 203, 187, 151, 8, 112, 166, 204, 244, 36, 58, 21, 24,
+			229, 137, 33, 19, 43, 90, 204, 1, 172, 17, 208, 232, 133, 110,
+			192, 198, 101, 71, 56, 86, 82, 182, 89, 98, 167, 46, 16, 100,
+			59, 194, 183, 203, 254, 137, 80, 40, 124, 113, 210, 141, 178, 232,
+			164, 129, 136, 121, 50, 59, 8, 157, 52, 88, 39, 79, 89, 224,
+			166, 206, 136, 12, 163, 6, 36, 133, 136, 121, 234, 200, 168, 164,
+			76, 98, 158, 178, 79, 138, 207, 16, 49, 79, 91, 68, 252, 132,
+			50, 140, 202, 74, 138, 253, 214, 43, 11, 65, 38, 49, 79, 15,
+			14, 137, 207, 12, 98, 158, 17, 67, 10, 190, 36, 230, 25, 49,
+			164, 224, 74, 98, 158, 17, 67, 10, 158, 36, 230, 153, 19, 54,
+			46, 240, 215, 136, 92, 234, 25, 100, 231, 196, 45, 133, 195, 219,
+			201, 77, 27, 82, 197, 139, 69, 7, 77, 68, 204, 92, 118, 8,
+			223, 22, 158, 38, 230, 69, 235, 156, 56, 38, 37, 171, 84, 223,
+			72, 60, 66, 249, 195, 84, 205, 221, 43, 56, 173, 86, 216, 242,
+			163, 66, 213, 111, 228, 196, 66, 4, 31, 18, 243, 162, 165, 40,
+			68, 204, 139, 125, 167, 36, 101, 18, 243, 226, 89, 138, 39, 192,
+			75, 194, 154, 76, 77, 33, 251, 20, 84, 168, 102, 14, 38, 114,
+			60, 212, 87, 155, 133, 136, 57, 153, 237, 199, 19, 194, 129, 194,
+			204, 91, 199, 133, 254, 65, 100, 78, 104, 120, 68, 75, 44, 104,
+			73, 222, 82, 20, 34, 102, 190, 111, 80, 82, 38, 49, 243, 199,
+			134, 25, 135, 99, 55, 193, 153, 212, 85, 198, 225, 88, 145, 236,
+			92, 225, 138, 112, 88, 245, 27, 149, 165, 80, 71, 46, 141, 61,
+			150, 64, 104, 81, 107, 111, 60, 132, 47, 227, 224, 91, 152, 251,
+			94, 152, 51, 217, 65, 124, 70, 184, 94, 152, 87, 44, 91, 196,
+			201, 58, 176, 133, 193, 65, 194, 188, 98, 41, 10, 17, 243, 74,
+			223, 113, 73, 153, 196, 188, 50, 118, 66, 73, 23, 159, 31, 122,
+			3, 210, 69, 43, 112, 107, 172, 31, 110, 151, 32, 54, 79, 23,
+			163, 38, 247, 41, 132, 7, 5, 16, 247, 170, 44, 149, 76, 225,
+			12, 132, 235, 145, 66, 64, 55, 216, 238, 187, 169, 138, 200, 68,
+			102, 113, 86, 42, 142, 31, 131, 243, 125, 55, 85, 81, 217, 200,
+			57, 8, 48, 177, 187, 41, 170, 1, 12, 250, 187, 41, 8, 49,
+			177, 91, 130, 180, 249, 62, 220, 171, 250, 153, 251, 176, 129, 135,
+			215, 221, 48, 186, 207, 111, 222, 113, 83, 79, 226, 222, 176, 189,
+			181, 9, 182, 42, 226, 252, 206, 134, 237, 173, 10, 163, 73, 25,
+			15, 73, 243, 19, 85, 148, 104, 225, 169, 46, 45, 84, 165, 86,
+			6, 247, 58, 135, 228, 54, 30, 84, 130, 176, 132, 214, 231, 160,
+			206, 9, 240, 229, 142, 8, 156, 149, 163, 97, 50, 129, 220, 193,
+			195, 73, 53, 241, 38, 216, 87, 10, 156, 242, 132, 180, 181, 238,
+			53, 220, 10, 251, 177, 66, 18, 32, 231, 144, 246, 148, 66, 194,
+			159, 246, 243, 96, 100, 115, 127, 99, 131, 145, 9, 112, 224, 108,
+			234, 57, 33, 210, 224, 88, 164, 97, 127, 94, 229, 167, 251, 64,
+			106, 12, 217, 19, 241, 43, 74, 72, 29, 186, 221, 110, 114, 75,
+			23, 49, 207, 116, 234, 69, 186, 229, 251, 117, 237, 68, 31, 200,
+			142, 225, 126, 118, 162, 103, 153, 132, 96, 140, 154, 252, 132, 205,
+			242, 211, 126, 8, 236, 54, 224, 180, 31, 180, 70, 237, 57, 90,
+			84, 170, 160, 142, 240, 86, 187, 50, 152, 43, 87, 23, 66, 76,
+			104, 112, 153, 21, 7, 118, 134, 149, 208, 167, 29, 230, 131, 253,
+			68, 59, 204, 7, 143, 143, 224, 151, 4, 38, 131, 121, 204, 58,
+			97, 223, 138, 171, 26, 15, 233, 3, 119, 127, 138, 91, 15, 182,
+			28, 47, 8, 121, 229, 18, 197, 22, 224, 139, 149, 250, 210, 111,
+			42, 192, 17, 56, 158, 142, 169, 90, 217, 241, 116, 172, 127, 88,
+			82, 38, 49, 143, 141, 142, 225, 23, 161, 86, 3, 78, 124, 123,
+			54, 81, 43, 219, 147, 188, 42, 173, 147, 188, 25, 157, 253, 51,
+			210, 172, 0, 69, 49, 33, 161, 111, 68, 82, 66, 72, 184, 206,
+			133, 149, 147, 169, 57, 100, 231, 15, 153, 40, 109, 171, 119, 76,
+			22, 130, 195, 251, 20, 4, 45, 231, 34, 202, 57, 99, 212, 254,
+			69, 20, 3, 148, 112, 141, 45, 127, 102, 158, 130, 71, 132, 26,
+			55, 101, 99, 204, 157, 3, 2, 113, 236, 171, 48, 242, 131, 125,
+			80, 234, 116, 104, 153, 120, 238, 135, 94, 180, 43, 81, 249, 114,
+			42, 66, 243, 141, 156, 8, 130, 201, 174, 255, 175, 236, 186, 205,
+			216, 220, 55, 31, 227, 116, 193, 29, 84, 214, 224, 54, 35, 0,
+			202, 73, 172, 115, 229, 182, 32, 172, 236, 224, 41, 73, 195, 117,
+			17, 67, 138, 224, 240, 56, 39, 34, 160, 113, 185, 235, 92, 47,
+			209, 228, 174, 115, 199, 71, 192, 39, 8, 177, 209, 190, 96, 76,
+			218, 111, 234, 24, 10, 13, 194, 43, 97, 133, 2, 207, 246, 92,
+			2, 86, 99, 1, 166, 177, 253, 210, 187, 219, 188, 96, 200, 154,
+			216, 176, 95, 56, 118, 65, 82, 38, 49, 47, 140, 79, 224, 47,
+			241, 73, 48, 136, 121, 217, 152, 179, 127, 83, 159, 4, 165, 25,
+			78, 68, 210, 149, 194, 46, 191, 52, 48, 102, 1, 42, 195, 46,
+			97, 117, 37, 62, 229, 29, 120, 66, 148, 169, 147, 218, 3, 164,
+			0, 71, 211, 188, 255, 5, 0, 14, 127, 119, 107, 198, 90, 208,
+			80, 159, 35, 167, 94, 143, 219, 227, 4, 26, 146, 206, 68, 224,
+			238, 56, 65, 173, 206, 13, 234, 49, 141, 219, 196, 207, 254, 73,
+			53, 54, 76, 112, 187, 108, 156, 146, 20, 34, 230, 229, 211, 83,
+			146, 50, 137, 121, 121, 102, 22, 255, 10, 31, 27, 147, 152, 179,
+			198, 69, 251, 231, 244, 177, 89, 19, 134, 127, 14, 183, 173, 135,
+			169, 145, 47, 25, 97, 114, 149, 38, 22, 18, 95, 170, 28, 108,
+			64, 37, 250, 245, 154, 116, 121, 226, 246, 195, 202, 154, 102, 125,
+			125, 137, 78, 60, 63, 3, 94, 109, 147, 124, 129, 53, 253, 40,
+			185, 200, 248, 18, 246, 121, 63, 243, 226, 7, 24, 163, 216, 238,
+			92, 111, 131, 26, 2, 51, 195, 58, 214, 39, 41, 68, 204, 217,
+			126, 42, 41, 214, 233, 243, 23, 148, 76, 243, 63, 247, 224, 194,
+			27, 144, 105, 184, 1, 249, 65, 125, 201, 219, 112, 143, 176, 46,
+			239, 26, 192, 231, 28, 238, 175, 121, 97, 171, 238, 236, 111, 194,
+			111, 60, 254, 66, 159, 72, 91, 102, 89, 180, 120, 57, 102, 34,
+			94, 206, 83, 158, 162, 127, 144, 230, 167, 232, 232, 223, 46, 220,
+			125, 204, 143, 78, 204, 250, 37, 15, 68, 156, 61, 202, 223, 123,
+			224, 212, 59, 106, 13, 219, 31, 122, 140, 41, 144, 116, 93, 231,
+			234, 182, 14, 129, 90, 170, 17, 226, 124, 79, 112, 90, 7, 83,
+			85, 120, 105, 56, 52, 214, 239, 92, 226, 126, 124, 52, 113, 63,
+			62, 218, 119, 84, 59, 82, 143, 114, 55, 37, 117, 166, 158, 178,
+			255, 129, 184, 152, 242, 5, 194, 251, 17, 197, 175, 26, 73, 135,
+			2, 225, 116, 0, 207, 134, 14, 223, 171, 10, 18, 128, 179, 25,
+			165, 48, 17, 32, 227, 220, 164, 83, 190, 120, 107, 69, 141, 135,
+			116, 169, 93, 245, 84, 201, 229, 69, 197, 245, 5, 16, 132, 56,
+			146, 211, 172, 157, 88, 63, 174, 213, 141, 31, 142, 107, 251, 36,
+			126, 46, 62, 174, 71, 5, 68, 149, 44, 214, 131, 46, 212, 219,
+			85, 239, 0, 214, 252, 99, 207, 105, 162, 159, 211, 199, 71, 212,
+			238, 253, 240, 177, 55, 16, 85, 83, 78, 99, 151, 11, 201, 83,
+			110, 253, 220, 113, 124, 108, 201, 11, 35, 177, 229, 101, 60, 141,
+			220, 29, 60, 156, 76, 142, 99, 223, 201, 202, 187, 197, 190, 19,
+			249, 43, 42, 83, 110, 10, 130, 150, 136, 244, 5, 96, 151, 143,
+			11, 38, 246, 67, 8, 15, 36, 50, 119, 101, 66, 47, 226, 172,
+			188, 65, 139, 11, 70, 174, 75, 43, 120, 1, 133, 123, 34, 103,
+			69, 125, 99, 191, 5, 103, 101, 234, 227, 163, 123, 73, 86, 199,
+			133, 16, 25, 221, 75, 164, 174, 66, 226, 220, 103, 16, 206, 202,
+			113, 34, 247, 112, 239, 29, 87, 54, 189, 67, 77, 217, 117, 20,
+			236, 19, 135, 182, 60, 151, 34, 111, 193, 22, 155, 6, 114, 86,
+			207, 212, 101, 190, 146, 65, 93, 186, 205, 220, 83, 43, 58, 255,
+			203, 0, 231, 190, 183, 254, 118, 113, 95, 21, 245, 228, 58, 94,
+			230, 106, 217, 35, 41, 27, 217, 243, 93, 67, 154, 28, 228, 165,
+			9, 227, 81, 112, 143, 140, 33, 75, 164, 50, 246, 72, 118, 16,
+			63, 148, 202, 88, 98, 44, 216, 239, 162, 119, 220, 40, 84, 175,
+			127, 73, 215, 49, 238, 125, 208, 225, 213, 5, 16, 199, 53, 55,
+			244, 118, 220, 166, 208, 207, 62, 17, 231, 120, 86, 195, 57, 102,
+			205, 32, 25, 29, 41, 155, 12, 142, 107, 10, 90, 50, 247, 38,
+			252, 35, 72, 32, 101, 155, 39, 140, 162, 253, 41, 68, 151, 224,
+			229, 72, 111, 73, 72, 247, 188, 208, 19, 96, 226, 82, 69, 35,
+			155, 7, 77, 226, 214, 197, 143, 109, 213, 92, 33, 137, 36, 30,
+			191, 88, 23, 232, 109, 30, 129, 126, 43, 112, 29, 176, 60, 22,
+			130, 39, 119, 103, 114, 31, 129, 105, 100, 125, 31, 64, 203, 119,
+			36, 207, 22, 48, 206, 39, 50, 88, 131, 113, 62, 209, 119, 70,
+			131, 113, 62, 49, 121, 11, 191, 3, 142, 89, 243, 76, 234, 140,
+			93, 209, 212, 207, 66, 71, 11, 77, 6, 101, 105, 228, 211, 109,
+			55, 18, 128, 127, 210, 161, 69, 205, 118, 44, 224, 225, 36, 62,
+			141, 60, 185, 207, 100, 79, 226, 13, 126, 199, 58, 151, 186, 128,
+			236, 178, 174, 16, 238, 208, 4, 31, 40, 61, 94, 75, 135, 172,
+			39, 184, 128, 157, 203, 158, 194, 115, 242, 254, 117, 222, 160, 2,
+			238, 224, 64, 105, 157, 223, 247, 75, 36, 44, 243, 188, 129, 53,
+			189, 242, 249, 62, 162, 221, 111, 206, 31, 59, 169, 221, 111, 206,
+			159, 57, 11, 2, 138, 65, 172, 103, 82, 151, 144, 210, 224, 62,
+			147, 61, 139, 93, 169, 193, 157, 52, 136, 253, 214, 3, 142, 188,
+			221, 17, 126, 222, 176, 131, 52, 255, 76, 52, 27, 240, 153, 204,
+			73, 35, 171, 233, 134, 39, 149, 146, 151, 53, 116, 114, 112, 8,
+			26, 106, 18, 43, 207, 120, 148, 212, 196, 230, 179, 199, 65, 181,
+			96, 154, 41, 98, 77, 25, 87, 185, 106, 193, 4, 24, 208, 41,
+			76, 112, 17, 103, 24, 197, 186, 49, 13, 202, 133, 132, 158, 86,
+			246, 1, 68, 19, 245, 122, 173, 251, 72, 23, 48, 62, 130, 123,
+			120, 17, 105, 86, 134, 70, 35, 98, 78, 247, 145, 152, 54, 137,
+			57, 125, 124, 4, 191, 93, 84, 137, 136, 121, 197, 58, 35, 244,
+			163, 225, 174, 31, 68, 117, 175, 249, 128, 114, 52, 211, 142, 42,
+			183, 218, 59, 16, 39, 253, 129, 4, 144, 137, 113, 80, 115, 213,
+			96, 171, 189, 35, 181, 197, 178, 50, 36, 180, 158, 138, 6, 189,
+			231, 137, 152, 54, 137, 121, 229, 212, 105, 14, 239, 4, 106, 234,
+			231, 13, 98, 63, 164, 149, 199, 9, 152, 29, 0, 225, 111, 100,
+			250, 158, 66, 154, 228, 74, 238, 231, 197, 36, 115, 37, 247, 243,
+			98, 146, 185, 146, 251, 249, 193, 33, 124, 79, 128, 36, 153, 47,
+			24, 99, 246, 155, 149, 33, 86, 140, 225, 255, 116, 147, 199, 11,
+			103, 215, 237, 23, 12, 69, 177, 210, 251, 142, 73, 202, 36, 230,
+			11, 35, 163, 74, 34, 251, 230, 91, 152, 118, 134, 42, 7, 187,
+			168, 205, 134, 19, 62, 16, 34, 216, 209, 142, 152, 137, 185, 115,
+			184, 247, 54, 203, 116, 207, 9, 31, 144, 97, 156, 110, 57, 209,
+			46, 23, 153, 122, 43, 156, 152, 255, 70, 132, 143, 85, 253, 70,
+			103, 184, 197, 249, 35, 234, 195, 85, 150, 180, 138, 222, 54, 39,
+			178, 236, 248, 117, 167, 185, 83, 240, 131, 29, 45, 106, 250, 126,
+			203, 13, 167, 31, 52, 253, 135, 77, 222, 44, 214, 170, 214, 214,
+			159, 33, 244, 73, 195, 188, 179, 58, 255, 3, 198, 153, 59, 252,
+			235, 85, 25, 207, 241, 21, 183, 94, 127, 11, 251, 96, 157, 125,
+			251, 210, 39, 110, 226, 30, 146, 62, 147, 250, 207, 8, 225, 207,
+			245, 195, 81, 127, 38, 69, 230, 126, 10, 194, 124, 69, 126, 213,
+			175, 211, 249, 246, 246, 182, 27, 132, 116, 138, 242, 178, 196, 203,
+			2, 15, 254, 35, 84, 248, 124, 29, 227, 132, 124, 48, 115, 93,
+			124, 64, 203, 205, 106, 129, 30, 34, 22, 200, 149, 162, 124, 143,
+			66, 57, 38, 85, 191, 193, 123, 90, 245, 235, 83, 91, 188, 17,
+			211, 236, 252, 113, 107, 94, 24, 5, 222, 86, 91, 133, 41, 225,
+			46, 55, 82, 172, 96, 41, 91, 94, 211, 225, 106, 150, 70, 152,
+			23, 161, 190, 2, 169, 104, 194, 9, 136, 181, 60, 199, 46, 116,
+			3, 161, 130, 80, 15, 94, 32, 118, 128, 79, 156, 95, 175, 251,
+			15, 5, 106, 62, 7, 131, 22, 199, 84, 195, 141, 110, 96, 76,
+			217, 127, 151, 58, 26, 6, 134, 101, 186, 160, 3, 122, 189, 192,
+			85, 225, 208, 156, 45, 127, 143, 253, 36, 70, 12, 220, 110, 188,
+			170, 43, 222, 45, 37, 135, 215, 107, 20, 239, 116, 113, 115, 106,
+			94, 88, 173, 59, 94, 3, 24, 69, 247, 70, 120, 77, 125, 44,
+			100, 35, 90, 129, 95, 107, 87, 221, 184, 29, 56, 110, 200, 127,
+			85, 59, 176, 148, 204, 106, 126, 181, 29, 195, 97, 59, 205, 218,
+			180, 31, 8, 208, 152, 134, 19, 185, 129, 231, 212, 195, 120, 168,
+			165, 233, 36, 166, 122, 235, 85, 167, 150, 93, 79, 25, 63, 74,
+			94, 165, 175, 173, 166, 31, 255, 6, 227, 14, 38, 236, 236, 4,
+			134, 162, 252, 24, 174, 76, 222, 148, 221, 102, 205, 15, 66, 8,
+			9, 212, 10, 252, 134, 31, 1, 227, 171, 181, 185, 87, 103, 140,
+			249, 143, 165, 192, 186, 29, 61, 100, 203, 68, 170, 42, 53, 16,
+			122, 143, 45, 172, 128, 173, 157, 38, 95, 69, 97, 200, 157, 20,
+			232, 250, 221, 242, 26, 93, 91, 185, 189, 254, 74, 177, 82, 162,
+			229, 53, 186, 90, 89, 185, 95, 94, 44, 45, 210, 249, 87, 233,
+			250, 221, 18, 93, 88, 89, 125, 181, 82, 190, 115, 119, 157, 222,
+			93, 89, 90, 44, 85, 214, 192, 149, 122, 97, 101, 121, 189, 82,
+			158, 223, 88, 95, 169, 172, 97, 154, 43, 174, 209, 242, 90, 14,
+			126, 41, 46, 191, 74, 75, 111, 93, 173, 148, 214, 214, 232, 74,
+			133, 150, 239, 173, 46, 149, 75, 139, 244, 149, 98, 165, 82, 92,
+			94, 47, 151, 214, 242, 180, 188, 188, 176, 180, 177, 88, 94, 190,
+			147, 167, 243, 27, 235, 116, 121, 101, 29, 211, 165, 242, 189, 242,
+			122, 105, 145, 174, 175, 228, 161, 218, 131, 223, 209, 149, 219, 244,
+			94, 169, 178, 112, 183, 184, 188, 94, 156, 47, 47, 149, 215, 95,
+			133, 10, 111, 151, 215, 151, 89, 101, 183, 87, 42, 152, 22, 233,
+			106, 177, 178, 94, 94, 216, 88, 42, 86, 232, 234, 70, 101, 117,
+			101, 173, 68, 89, 207, 22, 203, 107, 11, 75, 197, 242, 189, 210,
+			98, 129, 150, 151, 233, 242, 10, 45, 221, 47, 45, 175, 211, 181,
+			187, 197, 165, 165, 100, 71, 49, 93, 121, 101, 185, 84, 97, 173,
+			215, 187, 73, 231, 75, 116, 169, 92, 156, 95, 42, 177, 170, 160,
+			159, 139, 229, 74, 105, 97, 157, 117, 40, 254, 107, 161, 188, 88,
+			90, 94, 47, 46, 229, 49, 93, 91, 45, 45, 148, 139, 75, 121,
+			90, 122, 107, 233, 222, 234, 82, 177, 242, 106, 94, 20, 186, 86,
+			122, 121, 163, 180, 188, 94, 46, 46, 209, 197, 226, 189, 226, 157,
+			210, 26, 157, 120, 210, 168, 172, 86, 86, 22, 54, 42, 165, 123,
+			172, 213, 43, 183, 233, 218, 198, 252, 218, 122, 121, 125, 99, 189,
+			68, 239, 172, 172, 44, 194, 96, 175, 149, 42, 247, 203, 11, 165,
+			181, 155, 116, 105, 101, 13, 6, 108, 99, 173, 148, 199, 116, 177,
+			184, 94, 132, 170, 87, 43, 43, 183, 203, 235, 107, 55, 217, 223,
+			243, 27, 107, 101, 24, 184, 242, 242, 122, 169, 82, 217, 88, 93,
+			47, 175, 44, 79, 210, 187, 43, 175, 148, 238, 151, 42, 116, 161,
+			184, 177, 86, 90, 132, 17, 94, 89, 102, 189, 101, 107, 165, 180,
+			82, 121, 149, 21, 203, 198, 1, 102, 32, 79, 95, 185, 91, 90,
+			191, 91, 170, 176, 65, 133, 209, 42, 178, 97, 88, 91, 175, 148,
+			23, 214, 245, 108, 43, 21, 186, 190, 82, 89, 199, 90, 63, 233,
+			114, 233, 206, 82, 249, 78, 105, 121, 161, 196, 126, 94, 97, 197,
+			188, 82, 94, 43, 77, 210, 98, 165, 188, 198, 50, 148, 161, 98,
+			250, 74, 241, 85, 186, 178, 1, 189, 102, 19, 181, 177, 86, 194,
+			252, 111, 109, 233, 230, 97, 62, 105, 249, 54, 45, 46, 222, 47,
+			179, 150, 139, 220, 171, 43, 107, 107, 101, 177, 92, 96, 216, 22,
+			238, 138, 49, 87, 151, 49, 154, 26, 19, 151, 177, 28, 15, 43,
+			153, 189, 200, 255, 228, 137, 231, 83, 121, 72, 68, 252, 79, 158,
+			120, 33, 53, 13, 137, 226, 79, 158, 120, 49, 149, 131, 68, 204,
+			255, 228, 137, 207, 164, 206, 65, 226, 5, 254, 39, 79, 28, 79,
+			149, 229, 173, 143, 253, 201, 19, 39, 82, 103, 33, 241, 44, 255,
+			243, 71, 242, 112, 69, 200, 252, 9, 98, 71, 159, 253, 125, 121,
+			250, 154, 58, 121, 95, 75, 186, 124, 9, 149, 126, 184, 223, 216,
+			242, 235, 94, 85, 216, 69, 195, 49, 158, 231, 112, 185, 92, 58,
+			147, 7, 1, 252, 114, 131, 230, 182, 11, 78, 174, 51, 101, 171,
+			80, 203, 97, 76, 239, 186, 129, 75, 95, 219, 238, 168, 136, 23,
+			12, 231, 88, 195, 165, 129, 239, 71, 180, 225, 134, 161, 179, 227,
+			230, 233, 107, 206, 107, 192, 119, 95, 219, 122, 13, 75, 87, 122,
+			249, 190, 193, 243, 196, 23, 227, 215, 182, 95, 227, 126, 154, 175,
+			213, 94, 83, 197, 234, 215, 102, 172, 190, 129, 220, 133, 173, 215,
+			56, 76, 7, 203, 199, 36, 136, 80, 92, 73, 4, 108, 173, 210,
+			161, 199, 239, 27, 210, 233, 159, 135, 203, 84, 209, 228, 148, 130,
+			31, 46, 242, 59, 44, 111, 75, 26, 109, 130, 175, 123, 77, 153,
+			207, 58, 77, 30, 13, 199, 141, 179, 20, 58, 218, 0, 130, 36,
+			247, 202, 172, 182, 195, 200, 111, 208, 151, 214, 86, 150, 169, 219,
+			172, 250, 96, 39, 59, 17, 186, 46, 221, 114, 235, 254, 67, 112,
+			39, 58, 47, 190, 190, 7, 95, 123, 77, 121, 145, 5, 243, 79,
+			161, 126, 215, 213, 142, 236, 120, 112, 31, 41, 3, 120, 153, 55,
+			15, 232, 136, 226, 18, 39, 199, 201, 15, 224, 145, 98, 74, 141,
+			91, 24, 59, 202, 11, 91, 224, 226, 106, 153, 71, 63, 171, 239,
+			119, 60, 20, 74, 228, 131, 16, 31, 4, 82, 6, 49, 50, 41,
+			227, 11, 11, 85, 246, 75, 172, 33, 117, 247, 60, 191, 29, 226,
+			24, 176, 49, 97, 54, 223, 165, 197, 78, 40, 206, 231, 80, 174,
+			203, 109, 250, 58, 255, 131, 82, 135, 222, 160, 115, 115, 146, 218,
+			138, 127, 160, 180, 70, 111, 208, 217, 152, 124, 196, 114, 74, 242,
+			189, 242, 143, 125, 150, 233, 10, 214, 19, 223, 115, 131, 94, 199,
+			50, 186, 14, 183, 211, 150, 207, 32, 114, 52, 212, 153, 41, 112,
+			75, 216, 222, 17, 99, 243, 40, 207, 35, 98, 188, 7, 211, 9,
+			30, 97, 134, 191, 122, 74, 173, 45, 91, 118, 66, 13, 33, 32,
+			31, 68, 116, 89, 33, 179, 121, 16, 121, 137, 229, 112, 31, 69,
+			152, 250, 237, 168, 213, 142, 38, 111, 224, 175, 190, 235, 239, 149,
+			189, 195, 112, 169, 111, 241, 32, 137, 98, 139, 114, 235, 99, 136,
+			247, 227, 214, 168, 251, 168, 234, 182, 34, 234, 68, 49, 128, 82,
+			203, 15, 61, 229, 84, 136, 57, 3, 16, 33, 122, 196, 163, 153,
+			67, 21, 191, 145, 122, 9, 81, 172, 196, 0, 128, 168, 63, 137,
+			61, 148, 231, 155, 55, 222, 83, 124, 9, 112, 117, 85, 189, 46,
+			7, 115, 194, 1, 95, 149, 68, 13, 219, 90, 6, 76, 119, 29,
+			182, 95, 245, 23, 85, 216, 65, 203, 126, 36, 148, 109, 146, 107,
+			192, 34, 172, 197, 193, 42, 170, 108, 117, 5, 94, 125, 31, 170,
+			222, 23, 115, 130, 53, 195, 251, 206, 133, 8, 161, 105, 165, 138,
+			222, 161, 149, 210, 218, 122, 215, 78, 105, 245, 201, 94, 213, 188,
+			192, 173, 70, 170, 22, 85, 52, 119, 69, 244, 58, 139, 197, 66,
+			86, 77, 20, 44, 183, 81, 24, 185, 78, 77, 31, 47, 192, 56,
+			215, 130, 13, 136, 230, 226, 248, 141, 83, 176, 50, 245, 10, 195,
+			74, 239, 214, 27, 193, 155, 184, 234, 48, 143, 133, 144, 27, 155,
+			38, 36, 100, 79, 120, 174, 13, 220, 184, 105, 178, 73, 98, 165,
+			99, 90, 173, 187, 78, 80, 223, 87, 2, 52, 236, 240, 29, 254,
+			196, 203, 189, 62, 64, 68, 173, 214, 29, 137, 63, 213, 148, 252,
+			167, 64, 105, 185, 137, 33, 138, 83, 28, 7, 216, 221, 222, 6,
+			55, 223, 67, 186, 53, 45, 255, 128, 55, 121, 105, 105, 138, 101,
+			48, 192, 0, 118, 105, 113, 181, 28, 118, 101, 177, 27, 156, 133,
+			175, 180, 148, 157, 61, 219, 46, 218, 92, 122, 7, 217, 124, 252,
+			70, 172, 208, 50, 249, 186, 85, 33, 3, 157, 96, 71, 192, 116,
+			201, 27, 93, 224, 210, 29, 95, 184, 160, 176, 145, 132, 34, 107,
+			252, 213, 157, 49, 222, 68, 211, 21, 23, 142, 205, 203, 4, 199,
+			145, 193, 181, 36, 67, 238, 206, 143, 57, 214, 76, 221, 149, 113,
+			147, 97, 66, 67, 218, 110, 70, 126, 187, 186, 203, 170, 133, 253,
+			171, 90, 231, 133, 180, 229, 132, 242, 100, 241, 177, 180, 193, 20,
+			150, 109, 188, 173, 162, 9, 121, 117, 86, 112, 111, 170, 176, 163,
+			121, 78, 189, 174, 206, 119, 206, 59, 247, 244, 67, 6, 142, 11,
+			201, 64, 14, 114, 37, 45, 70, 131, 31, 116, 59, 98, 243, 0,
+			198, 40, 170, 99, 75, 14, 28, 187, 56, 240, 77, 236, 129, 229,
+			62, 18, 241, 150, 59, 107, 224, 99, 196, 231, 39, 134, 233, 139,
+			249, 6, 238, 218, 42, 152, 12, 201, 44, 69, 25, 7, 185, 36,
+			125, 13, 184, 228, 107, 157, 108, 178, 227, 216, 61, 48, 99, 7,
+			74, 82, 51, 204, 87, 32, 31, 9, 220, 101, 40, 192, 61, 85,
+			141, 135, 58, 107, 26, 110, 192, 237, 40, 59, 71, 67, 107, 9,
+			62, 108, 44, 112, 135, 194, 78, 128, 227, 199, 57, 69, 1, 93,
+			14, 229, 228, 89, 148, 60, 133, 187, 29, 194, 213, 27, 244, 237,
+			179, 239, 208, 142, 169, 102, 77, 155, 242, 167, 168, 103, 166, 107,
+			209, 115, 90, 209, 220, 145, 247, 192, 208, 122, 92, 182, 16, 226,
+			237, 219, 153, 124, 155, 203, 51, 49, 183, 154, 123, 135, 252, 42,
+			234, 16, 8, 182, 158, 174, 73, 143, 235, 123, 158, 206, 37, 186,
+			223, 25, 131, 139, 177, 93, 25, 5, 213, 111, 113, 214, 195, 120,
+			195, 158, 27, 4, 44, 77, 154, 144, 1, 130, 148, 206, 239, 112,
+			188, 136, 25, 39, 80, 2, 54, 108, 75, 88, 152, 77, 9, 118,
+			229, 131, 46, 75, 29, 152, 227, 210, 92, 171, 83, 88, 209, 70,
+			174, 29, 70, 88, 132, 78, 143, 79, 129, 102, 173, 139, 140, 35,
+			241, 235, 212, 51, 57, 87, 150, 104, 248, 152, 119, 221, 102, 149,
+			137, 140, 7, 219, 19, 139, 3, 190, 206, 172, 242, 113, 92, 88,
+			89, 11, 134, 227, 209, 105, 86, 53, 88, 78, 77, 147, 214, 81,
+			154, 214, 106, 136, 187, 87, 243, 177, 254, 50, 79, 29, 209, 161,
+			80, 113, 193, 26, 151, 209, 213, 134, 78, 44, 160, 132, 196, 227,
+			203, 213, 203, 199, 171, 155, 156, 131, 187, 10, 58, 90, 145, 9,
+			73, 7, 128, 8, 14, 8, 58, 154, 156, 19, 203, 215, 161, 43,
+			6, 32, 172, 238, 186, 13, 135, 186, 123, 126, 189, 45, 249, 132,
+			64, 150, 108, 184, 78, 83, 240, 57, 117, 1, 82, 70, 239, 74,
+			74, 122, 208, 244, 31, 10, 68, 25, 200, 16, 240, 104, 114, 224,
+			175, 230, 129, 231, 39, 99, 45, 88, 140, 52, 127, 153, 138, 109,
+			210, 226, 85, 224, 5, 114, 134, 98, 224, 31, 128, 39, 120, 232,
+			48, 169, 32, 62, 162, 217, 141, 69, 201, 214, 210, 191, 132, 181,
+			87, 70, 35, 118, 100, 27, 65, 78, 132, 248, 26, 226, 82, 135,
+			19, 131, 151, 23, 58, 48, 0, 137, 109, 82, 55, 8, 252, 128,
+			13, 112, 211, 143, 68, 232, 65, 14, 84, 168, 139, 111, 226, 68,
+			171, 75, 175, 185, 206, 37, 36, 208, 59, 229, 98, 8, 187, 156,
+			137, 29, 113, 92, 212, 126, 227, 49, 207, 67, 33, 186, 232, 114,
+			239, 3, 175, 89, 3, 81, 172, 187, 168, 211, 133, 81, 225, 216,
+			137, 134, 139, 16, 187, 62, 56, 51, 107, 183, 55, 16, 112, 206,
+			211, 5, 1, 164, 40, 100, 21, 118, 124, 220, 93, 95, 95, 229,
+			34, 38, 191, 224, 0, 205, 218, 32, 0, 87, 58, 143, 22, 33,
+			214, 180, 67, 55, 76, 46, 78, 181, 247, 197, 44, 175, 22, 215,
+			23, 238, 42, 233, 212, 223, 166, 171, 27, 235, 137, 205, 28, 58,
+			145, 23, 110, 239, 243, 26, 67, 183, 225, 52, 35, 175, 26, 98,
+			58, 193, 50, 130, 46, 87, 34, 135, 169, 135, 143, 237, 118, 189,
+			46, 90, 20, 138, 123, 49, 92, 155, 75, 242, 218, 236, 111, 235,
+			82, 28, 231, 102, 44, 71, 254, 192, 222, 132, 155, 182, 8, 13,
+			79, 67, 175, 185, 83, 151, 161, 237, 133, 91, 51, 191, 215, 128,
+			38, 92, 65, 77, 242, 187, 63, 160, 5, 22, 120, 69, 33, 215,
+			217, 202, 88, 159, 236, 35, 238, 163, 232, 55, 247, 220, 32, 226,
+			34, 219, 52, 184, 252, 66, 220, 250, 169, 170, 211, 112, 235, 236,
+			35, 161, 114, 223, 227, 104, 2, 161, 12, 33, 222, 140, 207, 87,
+			9, 123, 217, 161, 144, 142, 151, 144, 146, 147, 213, 61, 88, 254,
+			182, 26, 248, 219, 94, 221, 141, 143, 158, 141, 208, 13, 184, 19,
+			204, 45, 58, 123, 83, 166, 174, 238, 178, 123, 101, 11, 254, 127,
+			139, 206, 221, 76, 220, 122, 101, 89, 240, 169, 42, 72, 140, 145,
+			110, 43, 151, 40, 82, 252, 46, 225, 52, 244, 82, 97, 54, 248,
+			85, 214, 233, 148, 98, 94, 19, 45, 126, 13, 54, 119, 221, 247,
+			129, 205, 134, 237, 234, 174, 234, 26, 203, 170, 154, 33, 213, 78,
+			240, 112, 175, 55, 38, 215, 153, 3, 122, 151, 75, 180, 129, 175,
+			8, 133, 198, 34, 151, 132, 82, 88, 241, 101, 1, 156, 93, 214,
+			174, 42, 102, 153, 59, 234, 93, 118, 26, 110, 190, 179, 154, 228,
+			117, 130, 177, 204, 149, 166, 43, 151, 103, 216, 161, 146, 138, 2,
+			215, 137, 180, 83, 200, 103, 89, 67, 250, 46, 182, 9, 32, 148,
+			225, 14, 192, 139, 138, 67, 90, 109, 99, 113, 187, 236, 92, 25,
+			157, 139, 97, 13, 214, 211, 61, 65, 169, 158, 64, 37, 28, 72,
+			147, 255, 169, 201, 41, 98, 22, 197, 236, 94, 189, 25, 255, 178,
+			214, 222, 146, 37, 133, 237, 173, 77, 89, 199, 45, 250, 252, 205,
+			46, 234, 133, 245, 36, 175, 226, 118, 46, 143, 159, 81, 109, 18,
+			89, 9, 43, 193, 227, 179, 107, 141, 208, 190, 138, 79, 65, 209,
+			203, 253, 150, 124, 65, 153, 200, 197, 93, 206, 197, 56, 107, 78,
+			232, 78, 178, 246, 9, 219, 86, 161, 76, 19, 234, 13, 193, 62,
+			227, 25, 165, 247, 221, 64, 61, 178, 137, 126, 118, 72, 104, 192,
+			62, 121, 72, 121, 126, 125, 86, 94, 196, 97, 66, 125, 1, 77,
+			211, 239, 32, 113, 76, 123, 161, 125, 220, 99, 117, 137, 200, 205,
+			210, 90, 57, 161, 168, 141, 29, 33, 225, 54, 240, 90, 121, 249,
+			126, 113, 169, 188, 184, 89, 172, 220, 217, 184, 87, 90, 94, 127,
+			45, 62, 238, 88, 147, 128, 73, 193, 105, 219, 112, 90, 45, 103,
+			171, 46, 195, 80, 165, 16, 177, 254, 4, 101, 135, 112, 94, 134,
+			147, 252, 50, 50, 78, 217, 103, 244, 144, 214, 218, 108, 202, 161,
+			25, 224, 86, 146, 22, 100, 215, 67, 71, 126, 89, 6, 7, 226,
+			216, 85, 95, 70, 68, 198, 104, 76, 65, 48, 8, 251, 164, 122,
+			140, 254, 165, 23, 112, 254, 137, 22, 126, 0, 26, 217, 197, 54,
+			240, 172, 120, 198, 118, 90, 158, 120, 193, 150, 226, 3, 207, 108,
+			63, 241, 157, 91, 21, 17, 63, 63, 75, 83, 106, 145, 225, 41,
+			29, 162, 62, 148, 198, 86, 165, 93, 119, 187, 154, 249, 157, 142,
+			77, 243, 192, 242, 110, 222, 252, 141, 162, 25, 219, 231, 157, 194,
+			61, 172, 167, 155, 94, 141, 219, 25, 243, 159, 51, 44, 173, 92,
+			35, 121, 124, 20, 126, 141, 149, 48, 224, 245, 3, 185, 140, 202,
+			17, 246, 219, 162, 250, 137, 204, 96, 115, 171, 189, 51, 150, 6,
+			99, 194, 132, 73, 94, 81, 129, 187, 204, 183, 119, 248, 199, 44,
+			43, 57, 137, 123, 189, 112, 211, 169, 70, 222, 158, 59, 150, 161,
+			104, 34, 91, 201, 122, 97, 17, 104, 242, 12, 62, 234, 133, 155,
+			13, 167, 233, 236, 120, 205, 157, 77, 86, 244, 17, 200, 50, 224,
+			133, 247, 68, 234, 124, 123, 135, 188, 128, 143, 112, 25, 105, 83,
+			96, 223, 141, 245, 28, 244, 76, 18, 88, 116, 229, 90, 101, 128,
+			103, 22, 9, 228, 205, 184, 15, 66, 44, 184, 224, 218, 52, 150,
+			133, 79, 237, 78, 123, 130, 130, 194, 237, 225, 3, 132, 249, 55,
+			44, 149, 92, 80, 37, 128, 83, 121, 111, 60, 140, 34, 23, 56,
+			118, 151, 241, 32, 187, 97, 111, 114, 137, 130, 87, 134, 223, 88,
+			101, 71, 216, 135, 92, 67, 4, 21, 78, 37, 139, 130, 90, 251,
+			226, 90, 181, 236, 80, 243, 223, 193, 182, 114, 37, 219, 60, 208,
+			134, 129, 55, 214, 134, 81, 85, 196, 82, 178, 49, 4, 91, 110,
+			228, 236, 140, 245, 243, 53, 199, 254, 206, 77, 226, 35, 119, 220,
+			136, 45, 73, 105, 166, 58, 170, 175, 76, 190, 0, 184, 173, 234,
+			52, 30, 92, 242, 66, 200, 43, 237, 48, 201, 73, 156, 105, 57,
+			129, 219, 140, 244, 236, 34, 41, 119, 19, 15, 105, 31, 8, 139,
+			218, 103, 112, 26, 246, 171, 48, 167, 77, 120, 244, 67, 51, 248,
+			207, 185, 183, 227, 161, 5, 152, 18, 189, 109, 143, 171, 142, 76,
+			98, 139, 125, 218, 13, 42, 128, 149, 33, 186, 194, 178, 228, 62,
+			140, 240, 16, 31, 24, 189, 116, 89, 0, 122, 98, 1, 228, 38,
+			238, 19, 51, 195, 152, 133, 168, 242, 224, 204, 40, 118, 94, 193,
+			60, 59, 152, 199, 200, 121, 48, 181, 121, 88, 196, 131, 75, 190,
+			255, 160, 221, 154, 111, 239, 104, 189, 229, 200, 42, 137, 222, 242,
+			36, 114, 12, 27, 94, 45, 230, 19, 70, 197, 240, 106, 185, 73,
+			60, 164, 149, 34, 70, 124, 88, 142, 184, 193, 173, 113, 128, 152,
+			251, 121, 3, 167, 97, 102, 200, 53, 108, 222, 113, 35, 98, 119,
+			88, 239, 106, 35, 99, 31, 24, 139, 92, 138, 148, 132, 173, 238,
+			169, 78, 83, 92, 125, 129, 216, 167, 15, 249, 85, 90, 233, 146,
+			155, 56, 195, 231, 153, 36, 178, 30, 152, 251, 174, 109, 184, 137,
+			51, 124, 26, 147, 31, 31, 152, 218, 174, 31, 191, 132, 123, 213,
+			96, 117, 244, 162, 99, 38, 58, 122, 209, 57, 194, 79, 109, 107,
+			252, 127, 21, 112, 15, 73, 91, 169, 31, 70, 127, 99, 141, 141,
+			149, 195, 228, 36, 252, 137, 136, 217, 155, 186, 4, 127, 26, 196,
+			196, 34, 213, 36, 102, 159, 242, 168, 28, 136, 13, 147, 217, 159,
+			46, 183, 70, 30, 74, 77, 34, 251, 213, 174, 214, 200, 13, 167,
+			233, 241, 216, 64, 2, 22, 218, 107, 74, 107, 207, 124, 236, 254,
+			33, 143, 43, 172, 161, 147, 178, 27, 251, 86, 123, 71, 71, 140,
+			24, 202, 14, 224, 117, 105, 164, 60, 108, 92, 182, 239, 208, 138,
+			176, 211, 12, 69, 200, 160, 175, 2, 34, 34, 105, 129, 60, 156,
+			233, 213, 44, 144, 135, 241, 152, 102, 129, 60, 124, 126, 28, 255,
+			132, 178, 64, 62, 105, 220, 180, 127, 72, 90, 32, 115, 241, 5,
+			211, 245, 149, 197, 149, 27, 177, 184, 72, 91, 236, 216, 84, 86,
+			165, 13, 231, 129, 27, 199, 224, 255, 255, 218, 26, 249, 100, 194,
+			26, 249, 100, 223, 73, 205, 26, 249, 228, 51, 207, 225, 251, 208,
+			83, 131, 152, 103, 141, 89, 187, 76, 249, 110, 14, 181, 192, 72,
+			95, 197, 96, 95, 81, 45, 96, 67, 120, 54, 211, 47, 41, 86,
+			205, 128, 180, 135, 54, 76, 98, 158, 157, 156, 194, 21, 104, 129,
+			73, 204, 243, 198, 172, 93, 18, 175, 54, 255, 21, 83, 125, 85,
+			213, 206, 22, 251, 121, 85, 59, 91, 239, 231, 85, 237, 108, 201,
+			159, 159, 156, 194, 255, 152, 79, 181, 69, 204, 9, 227, 205, 246,
+			247, 34, 202, 152, 70, 72, 219, 45, 174, 245, 105, 215, 221, 3,
+			40, 122, 50, 170, 236, 86, 123, 39, 175, 108, 174, 30, 52, 253,
+			135, 117, 183, 182, 227, 42, 160, 89, 105, 85, 47, 173, 63, 85,
+			121, 50, 194, 241, 87, 15, 115, 98, 33, 98, 78, 40, 43, 122,
+			203, 32, 230, 196, 32, 149, 148, 73, 204, 137, 203, 47, 0, 24,
+			72, 138, 88, 83, 41, 95, 128, 129, 232, 93, 97, 139, 40, 185,
+			11, 69, 172, 108, 186, 236, 62, 138, 104, 121, 241, 6, 157, 189,
+			166, 25, 147, 79, 101, 251, 241, 247, 41, 55, 176, 89, 131, 216,
+			31, 69, 58, 218, 28, 215, 237, 110, 115, 189, 16, 15, 33, 220,
+			174, 75, 80, 9, 175, 73, 225, 44, 89, 227, 218, 63, 118, 100,
+			177, 69, 254, 132, 8, 74, 16, 247, 235, 117, 33, 71, 127, 53,
+			222, 96, 179, 194, 126, 151, 111, 249, 89, 97, 191, 203, 55, 249,
+			236, 224, 16, 126, 179, 12, 122, 127, 213, 184, 38, 129, 126, 15,
+			13, 185, 47, 122, 68, 61, 21, 164, 60, 225, 167, 117, 85, 213,
+			197, 246, 220, 213, 222, 65, 205, 79, 235, 234, 49, 229, 100, 157,
+			37, 230, 53, 107, 26, 31, 197, 89, 160, 190, 59, 155, 34, 230,
+			181, 116, 1, 183, 132, 27, 151, 245, 188, 113, 99, 198, 222, 58,
+			36, 30, 188, 68, 160, 125, 76, 96, 116, 39, 164, 87, 230, 184,
+			162, 10, 30, 130, 119, 221, 71, 78, 205, 173, 122, 13, 167, 206,
+			152, 70, 224, 84, 5, 70, 127, 236, 255, 245, 188, 106, 186, 1,
+			102, 206, 131, 154, 255, 215, 243, 170, 233, 70, 150, 152, 55, 84,
+			211, 13, 222, 244, 27, 233, 2, 126, 25, 154, 110, 18, 235, 150,
+			241, 226, 148, 189, 192, 109, 44, 216, 72, 41, 217, 54, 47, 158,
+			155, 65, 49, 39, 227, 15, 194, 194, 115, 24, 79, 3, 32, 179,
+			120, 139, 169, 182, 153, 105, 98, 222, 82, 109, 99, 91, 249, 86,
+			239, 152, 164, 76, 98, 222, 58, 121, 74, 82, 89, 98, 190, 104,
+			229, 69, 219, 76, 222, 182, 23, 211, 151, 133, 51, 187, 69, 172,
+			162, 49, 63, 101, 207, 168, 184, 7, 177, 237, 172, 222, 146, 142,
+			109, 174, 26, 98, 101, 136, 89, 52, 108, 73, 33, 98, 22, 79,
+			158, 145, 148, 73, 204, 226, 185, 156, 164, 178, 196, 156, 87, 13,
+			177, 120, 67, 230, 211, 151, 241, 223, 133, 134, 164, 137, 121, 219,
+			56, 110, 251, 29, 112, 153, 59, 58, 154, 178, 208, 61, 199, 30,
+			29, 92, 57, 240, 80, 124, 33, 27, 140, 229, 55, 97, 36, 31,
+			6, 157, 8, 98, 8, 57, 59, 142, 215, 12, 99, 110, 163, 186,
+			145, 134, 234, 51, 146, 66, 196, 188, 221, 35, 231, 58, 109, 18,
+			243, 246, 177, 97, 252, 237, 124, 139, 103, 136, 249, 146, 113, 202,
+			126, 127, 12, 236, 41, 121, 153, 168, 21, 238, 143, 174, 120, 157,
+			240, 252, 192, 139, 184, 133, 12, 40, 58, 60, 229, 41, 173, 88,
+			161, 54, 180, 172, 191, 42, 212, 158, 6, 118, 231, 2, 166, 191,
+			23, 178, 62, 236, 121, 78, 188, 229, 84, 7, 50, 105, 214, 44,
+			217, 129, 12, 34, 230, 75, 61, 18, 84, 32, 99, 18, 243, 165,
+			19, 39, 241, 119, 26, 208, 129, 30, 98, 174, 26, 151, 236, 15,
+			10, 40, 205, 206, 112, 126, 218, 126, 230, 56, 165, 226, 77, 56,
+			240, 27, 9, 252, 58, 192, 9, 78, 134, 227, 130, 56, 249, 2,
+			75, 83, 62, 163, 4, 110, 173, 93, 21, 209, 19, 2, 55, 228,
+			38, 27, 188, 91, 106, 4, 164, 156, 39, 163, 28, 234, 177, 1,
+			104, 121, 145, 70, 110, 189, 206, 157, 43, 182, 121, 168, 133, 200,
+			23, 143, 240, 184, 203, 247, 108, 61, 52, 85, 224, 6, 161, 242,
+			134, 137, 98, 167, 54, 19, 168, 68, 12, 70, 159, 199, 65, 168,
+			107, 251, 170, 39, 195, 6, 71, 14, 92, 15, 34, 230, 234, 232,
+			69, 73, 153, 196, 92, 157, 152, 196, 5, 24, 197, 44, 177, 214,
+			140, 245, 25, 155, 114, 200, 94, 175, 161, 135, 93, 140, 199, 77,
+			149, 252, 255, 176, 247, 254, 177, 113, 93, 87, 126, 56, 239, 251,
+			49, 26, 94, 146, 18, 245, 36, 145, 212, 35, 41, 61, 81, 63,
+			204, 31, 51, 35, 82, 18, 229, 232, 103, 66, 83, 84, 36, 88,
+			142, 108, 82, 178, 101, 199, 49, 249, 200, 121, 36, 199, 30, 206,
+			208, 243, 102, 36, 49, 246, 215, 70, 126, 56, 206, 23, 241, 110,
+			208, 181, 93, 120, 131, 172, 157, 181, 131, 38, 118, 155, 212, 216,
+			34, 88, 23, 112, 129, 52, 49, 208, 2, 46, 10, 183, 155, 38,
+			91, 103, 81, 20, 65, 209, 194, 40, 22, 117, 183, 187, 238, 31,
+			217, 63, 138, 123, 206, 61, 247, 222, 55, 67, 82, 148, 236, 117,
+			215, 169, 13, 200, 224, 189, 243, 222, 189, 231, 221, 95, 231, 220,
+			123, 207, 249, 124, 210, 41, 207, 158, 178, 186, 41, 197, 60, 123,
+			170, 231, 54, 74, 217, 158, 61, 53, 56, 68, 169, 180, 103, 95,
+			82, 171, 73, 26, 39, 202, 37, 55, 199, 135, 161, 226, 102, 207,
+			185, 207, 186, 50, 44, 129, 142, 224, 208, 91, 70, 20, 203, 158,
+			106, 24, 220, 205, 174, 103, 223, 167, 22, 139, 102, 230, 217, 247,
+			53, 211, 210, 213, 108, 123, 246, 125, 157, 180, 116, 52, 167, 61,
+			251, 138, 170, 186, 25, 171, 190, 226, 230, 248, 97, 168, 154, 123,
+			206, 23, 173, 7, 135, 101, 36, 83, 227, 55, 195, 133, 62, 121,
+			120, 80, 237, 60, 229, 217, 95, 84, 31, 206, 153, 103, 127, 177,
+			39, 67, 41, 219, 179, 191, 120, 112, 132, 82, 105, 207, 126, 80,
+			213, 206, 177, 246, 7, 221, 28, 31, 133, 218, 91, 60, 103, 218,
+			154, 25, 246, 111, 171, 255, 112, 179, 218, 198, 175, 111, 113, 61,
+			123, 90, 125, 125, 11, 243, 236, 233, 230, 157, 148, 178, 61, 123,
+			186, 135, 214, 171, 150, 180, 103, 207, 168, 250, 91, 176, 254, 25,
+			55, 199, 95, 193, 137, 211, 234, 57, 11, 214, 226, 176, 255, 156,
+			181, 129, 239, 71, 39, 176, 107, 225, 10, 109, 75, 208, 18, 0,
+			23, 78, 185, 26, 169, 5, 86, 1, 42, 101, 2, 160, 43, 49,
+			168, 88, 66, 229, 11, 83, 14, 234, 14, 198, 120, 80, 174, 4,
+			234, 48, 43, 23, 244, 223, 129, 238, 139, 149, 48, 174, 102, 80,
+			12, 10, 97, 171, 91, 98, 224, 194, 243, 11, 23, 47, 201, 178,
+			185, 6, 219, 48, 164, 202, 4, 113, 89, 63, 41, 47, 202, 52,
+			188, 112, 110, 128, 7, 23, 193, 89, 15, 46, 178, 84, 115, 183,
+			166, 60, 123, 65, 117, 119, 43, 243, 236, 133, 158, 163, 148, 178,
+			61, 123, 225, 216, 9, 74, 165, 61, 123, 81, 53, 119, 43, 54,
+			247, 162, 155, 227, 255, 21, 23, 218, 54, 207, 46, 89, 219, 252,
+			63, 103, 104, 249, 3, 50, 104, 92, 91, 162, 85, 166, 86, 53,
+			208, 92, 17, 14, 45, 177, 116, 42, 194, 102, 112, 2, 162, 123,
+			95, 212, 24, 210, 173, 43, 121, 117, 77, 7, 211, 232, 100, 86,
+			138, 107, 21, 19, 26, 141, 227, 209, 182, 120, 56, 91, 45, 103,
+			225, 5, 172, 98, 22, 111, 137, 151, 43, 101, 13, 218, 124, 67,
+			115, 108, 244, 136, 106, 177, 54, 87, 124, 40, 13, 208, 54, 230,
+			217, 37, 101, 142, 181, 217, 158, 93, 106, 247, 32, 102, 142, 121,
+			206, 163, 77, 87, 153, 10, 48, 124, 52, 221, 193, 31, 146, 1,
+			134, 78, 213, 170, 101, 253, 187, 27, 105, 58, 107, 197, 155, 97,
+			59, 173, 55, 43, 13, 120, 149, 106, 2, 94, 165, 42, 37, 196,
+			240, 195, 106, 251, 86, 74, 165, 61, 187, 38, 149, 188, 72, 65,
+			159, 214, 220, 33, 138, 78, 188, 222, 244, 255, 233, 232, 196, 235,
+			233, 46, 254, 37, 25, 157, 232, 60, 102, 61, 158, 245, 47, 18,
+			146, 120, 84, 170, 18, 163, 79, 249, 26, 81, 45, 72, 54, 0,
+			121, 253, 64, 251, 205, 117, 153, 253, 117, 84, 226, 99, 137, 168,
+			196, 199, 154, 183, 24, 81, 137, 143, 121, 219, 40, 149, 246, 236,
+			199, 165, 252, 22, 201, 255, 184, 59, 196, 91, 32, 104, 209, 125,
+			178, 233, 171, 76, 71, 45, 62, 153, 222, 201, 125, 25, 152, 231,
+			124, 133, 89, 190, 223, 170, 76, 188, 88, 113, 233, 55, 57, 240,
+			163, 74, 166, 68, 82, 50, 154, 66, 248, 156, 243, 21, 214, 190,
+			131, 146, 182, 72, 118, 237, 36, 46, 253, 175, 179, 166, 223, 51,
+			184, 244, 191, 206, 210, 59, 249, 172, 132, 130, 75, 61, 205, 172,
+			111, 178, 172, 63, 105, 180, 154, 121, 125, 79, 222, 138, 146, 10,
+			24, 77, 33, 165, 151, 214, 111, 184, 54, 2, 145, 115, 158, 102,
+			86, 218, 224, 221, 127, 154, 53, 111, 49, 120, 247, 159, 102, 222,
+			54, 74, 166, 61, 231, 155, 204, 201, 240, 118, 158, 134, 164, 104,
+			60, 231, 155, 204, 29, 226, 251, 65, 98, 230, 165, 190, 197, 172,
+			103, 88, 214, 223, 161, 45, 97, 177, 246, 129, 72, 170, 82, 150,
+			242, 156, 111, 49, 43, 69, 73, 38, 146, 155, 90, 40, 105, 139,
+			228, 102, 146, 129, 165, 61, 231, 25, 93, 41, 110, 27, 156, 103,
+			68, 165, 173, 128, 110, 151, 250, 54, 107, 122, 81, 182, 160, 203,
+			60, 231, 219, 162, 5, 159, 103, 18, 147, 46, 245, 28, 179, 158,
+			103, 89, 255, 235, 44, 33, 17, 46, 8, 57, 242, 138, 174, 65,
+			232, 220, 140, 152, 88, 51, 218, 57, 78, 33, 41, 226, 38, 4,
+			87, 162, 217, 114, 249, 17, 179, 132, 155, 155, 110, 200, 148, 44,
+			70, 200, 115, 212, 0, 128, 133, 231, 60, 71, 13, 0, 96, 120,
+			206, 115, 212, 0, 46, 180, 250, 243, 212, 0, 46, 181, 250, 243,
+			162, 1, 178, 240, 145, 204, 115, 94, 96, 86, 198, 223, 157, 8,
+			63, 214, 220, 127, 234, 91, 177, 60, 209, 250, 47, 48, 171, 155,
+			146, 240, 122, 207, 109, 148, 180, 69, 114, 112, 8, 41, 109, 92,
+			177, 43, 251, 35, 102, 121, 254, 15, 176, 253, 8, 179, 50, 170,
+			134, 11, 230, 18, 132, 48, 32, 97, 9, 127, 40, 24, 225, 99,
+			98, 53, 86, 254, 55, 160, 130, 130, 234, 58, 5, 101, 148, 55,
+			32, 141, 103, 66, 121, 14, 209, 135, 110, 236, 142, 139, 147, 151,
+			38, 206, 200, 43, 193, 85, 0, 163, 240, 59, 44, 23, 4, 79,
+			83, 146, 137, 100, 115, 27, 37, 109, 145, 132, 69, 13, 168, 170,
+			191, 199, 154, 254, 177, 28, 66, 41, 230, 57, 223, 99, 233, 46,
+			192, 129, 78, 137, 17, 244, 10, 179, 190, 207, 178, 254, 3, 193,
+			20, 156, 104, 107, 20, 118, 25, 10, 12, 254, 135, 248, 147, 194,
+			171, 95, 80, 232, 231, 1, 198, 199, 113, 13, 46, 217, 7, 116,
+			165, 179, 181, 133, 176, 84, 248, 114, 84, 233, 75, 208, 77, 191,
+			66, 50, 35, 221, 244, 43, 52, 25, 145, 110, 250, 21, 154, 140,
+			41, 24, 22, 223, 167, 97, 145, 162, 97, 241, 125, 49, 44, 126,
+			204, 64, 116, 230, 165, 94, 101, 214, 107, 44, 235, 255, 17, 11,
+			206, 231, 215, 145, 59, 171, 92, 148, 234, 25, 47, 240, 84, 228,
+			44, 4, 136, 160, 244, 210, 241, 96, 110, 49, 66, 14, 8, 61,
+			218, 31, 43, 213, 150, 162, 74, 97, 78, 12, 245, 12, 250, 234,
+			169, 143, 172, 127, 201, 120, 84, 125, 62, 115, 61, 231, 85, 253,
+			249, 98, 96, 190, 202, 154, 91, 40, 105, 139, 228, 102, 106, 13,
+			177, 44, 188, 166, 63, 95, 46, 11, 175, 209, 178, 176, 201, 75,
+			253, 136, 193, 185, 53, 145, 94, 255, 72, 44, 11, 15, 19, 233,
+			245, 235, 204, 234, 145, 216, 222, 120, 102, 219, 128, 166, 111, 120,
+			61, 161, 81, 117, 43, 83, 125, 19, 232, 134, 215, 73, 55, 32,
+			249, 245, 235, 116, 173, 140, 228, 215, 175, 211, 181, 50, 146, 95,
+			191, 110, 94, 43, 255, 249, 126, 190, 171, 254, 102, 55, 79, 113,
+			223, 107, 68, 56, 31, 231, 233, 51, 242, 17, 175, 139, 111, 138,
+			163, 185, 114, 41, 143, 24, 150, 246, 36, 37, 189, 237, 220, 45,
+			133, 165, 50, 66, 85, 186, 147, 152, 184, 227, 107, 107, 132, 62,
+			183, 81, 137, 20, 249, 60, 178, 193, 200, 103, 18, 246, 166, 2,
+			159, 223, 221, 203, 83, 158, 179, 171, 41, 254, 52, 238, 249, 211,
+			184, 231, 79, 227, 158, 63, 141, 123, 254, 52, 238, 249, 211, 184,
+			231, 143, 51, 238, 89, 133, 35, 139, 63, 41, 238, 249, 28, 221,
+			10, 139, 63, 41, 238, 89, 69, 72, 239, 87, 17, 210, 7, 154,
+			114, 20, 33, 45, 254, 164, 184, 103, 21, 33, 125, 155, 138, 144,
+			238, 215, 17, 210, 226, 207, 23, 219, 241, 206, 106, 190, 41, 102,
+			254, 63, 104, 15, 198, 2, 210, 186, 117, 65, 207, 112, 109, 150,
+			65, 130, 215, 108, 49, 42, 45, 84, 23, 131, 120, 57, 44, 17,
+			132, 166, 233, 9, 202, 209, 67, 24, 184, 219, 96, 245, 71, 253,
+			15, 11, 230, 124, 37, 156, 211, 106, 129, 126, 168, 6, 96, 11,
+			64, 146, 195, 110, 19, 157, 237, 115, 193, 249, 42, 94, 222, 161,
+			243, 119, 132, 5, 162, 155, 119, 49, 42, 229, 67, 132, 171, 159,
+			43, 151, 230, 162, 229, 170, 88, 166, 31, 137, 130, 190, 124, 184,
+			210, 7, 7, 89, 194, 254, 173, 46, 246, 81, 49, 149, 168, 24,
+			74, 42, 81, 77, 96, 86, 40, 105, 13, 151, 47, 8, 245, 10,
+			174, 255, 179, 81, 245, 90, 20, 149, 120, 80, 189, 102, 62, 77,
+			46, 234, 226, 3, 85, 83, 1, 129, 182, 130, 51, 15, 243, 121,
+			164, 115, 138, 107, 179, 85, 184, 255, 201, 115, 60, 233, 14, 117,
+			65, 185, 0, 192, 164, 101, 224, 110, 165, 124, 189, 32, 212, 65,
+			113, 37, 24, 202, 142, 12, 103, 134, 135, 135, 131, 149, 40, 172,
+			200, 40, 59, 73, 146, 26, 3, 239, 13, 6, 252, 142, 28, 15,
+			198, 241, 188, 74, 139, 129, 164, 221, 166, 184, 160, 247, 150, 227,
+			168, 150, 47, 131, 242, 205, 73, 37, 173, 191, 7, 105, 209, 78,
+			5, 185, 92, 238, 68, 253, 111, 81, 41, 159, 248, 69, 85, 68,
+			22, 22, 253, 138, 63, 43, 35, 145, 186, 245, 148, 40, 65, 165,
+			178, 88, 23, 165, 79, 212, 189, 4, 3, 64, 190, 130, 127, 211,
+			11, 144, 162, 74, 10, 243, 65, 127, 67, 69, 39, 131, 225, 224,
+			192, 129, 250, 178, 78, 7, 195, 3, 218, 249, 181, 225, 165, 33,
+			211, 5, 187, 238, 213, 236, 169, 96, 100, 152, 254, 35, 103, 236,
+			32, 42, 198, 209, 234, 2, 156, 94, 85, 128, 147, 235, 11, 144,
+			93, 71, 128, 161, 213, 4, 48, 186, 255, 144, 238, 126, 221, 95,
+			208, 255, 58, 57, 164, 59, 236, 230, 71, 193, 154, 125, 189, 246,
+			24, 193, 159, 204, 46, 63, 149, 236, 242, 96, 168, 161, 17, 78,
+			232, 151, 104, 0, 24, 157, 110, 190, 208, 48, 10, 244, 59, 201,
+			118, 78, 140, 57, 179, 137, 245, 11, 171, 182, 174, 238, 94, 253,
+			224, 105, 243, 193, 53, 234, 24, 90, 189, 142, 85, 135, 144, 209,
+			131, 135, 215, 154, 192, 249, 176, 26, 1, 77, 161, 248, 95, 62,
+			42, 194, 22, 35, 184, 123, 165, 186, 136, 214, 148, 248, 175, 42,
+			26, 189, 241, 193, 254, 124, 184, 18, 159, 58, 156, 9, 150, 10,
+			165, 90, 53, 138, 79, 141, 12, 15, 36, 167, 89, 112, 74, 213,
+			214, 95, 247, 83, 238, 108, 165, 188, 116, 73, 21, 85, 205, 15,
+			232, 96, 145, 187, 194, 229, 229, 66, 105, 65, 135, 2, 200, 157,
+			14, 110, 204, 149, 252, 224, 151, 221, 16, 40, 130, 126, 241, 149,
+			80, 154, 174, 33, 122, 92, 99, 144, 123, 198, 56, 81, 148, 15,
+			66, 140, 143, 180, 158, 227, 218, 252, 124, 225, 122, 208, 23, 247,
+			5, 253, 133, 18, 92, 110, 195, 9, 2, 54, 61, 208, 130, 114,
+			60, 249, 137, 230, 144, 211, 14, 15, 204, 52, 39, 149, 124, 84,
+			238, 125, 180, 150, 137, 201, 137, 7, 196, 228, 74, 47, 1, 187,
+			7, 60, 80, 7, 195, 112, 88, 105, 43, 40, 105, 56, 81, 150,
+			129, 121, 65, 223, 94, 72, 52, 148, 104, 138, 190, 195, 113, 31,
+			156, 63, 139, 222, 79, 40, 197, 17, 163, 48, 147, 97, 81, 139,
+			184, 90, 105, 57, 26, 93, 35, 162, 92, 81, 78, 93, 169, 60,
+			88, 42, 204, 85, 146, 229, 110, 180, 216, 145, 184, 15, 156, 81,
+			200, 29, 101, 62, 221, 206, 255, 66, 185, 163, 60, 108, 109, 247,
+			255, 45, 11, 166, 208, 153, 134, 42, 165, 139, 87, 195, 44, 200,
+			5, 119, 73, 152, 126, 24, 219, 217, 195, 35, 163, 153, 209, 219,
+			143, 10, 5, 39, 254, 65, 112, 246, 80, 93, 38, 122, 236, 199,
+			112, 253, 244, 133, 114, 53, 58, 30, 32, 129, 216, 108, 185, 6,
+			159, 6, 161, 66, 242, 170, 70, 148, 122, 156, 7, 71, 135, 133,
+			16, 7, 151, 10, 165, 96, 80, 36, 150, 10, 165, 131, 139, 149,
+			96, 48, 56, 116, 36, 88, 172, 28, 204, 135, 43, 193, 96, 112,
+			248, 232, 104, 238, 208, 40, 128, 114, 31, 20, 202, 53, 24, 196,
+			25, 138, 154, 214, 244, 99, 121, 216, 218, 100, 248, 177, 60, 156,
+			54, 81, 141, 31, 246, 182, 241, 175, 217, 132, 106, 92, 177, 60,
+			255, 111, 44, 106, 136, 132, 113, 19, 202, 118, 73, 90, 55, 134,
+			113, 99, 182, 23, 215, 13, 70, 179, 41, 14, 138, 8, 56, 26,
+			66, 112, 139, 42, 173, 146, 176, 181, 200, 149, 104, 152, 7, 51,
+			178, 31, 232, 52, 25, 14, 47, 101, 40, 244, 85, 216, 226, 149,
+			162, 133, 16, 254, 158, 1, 129, 228, 131, 56, 208, 105, 25, 64,
+			23, 2, 163, 66, 56, 135, 171, 68, 153, 32, 4, 66, 255, 47,
+			71, 149, 178, 188, 163, 34, 239, 152, 68, 105, 196, 203, 160, 239,
+			225, 197, 70, 85, 216, 143, 1, 198, 63, 214, 203, 89, 63, 68,
+			142, 29, 59, 150, 145, 255, 112, 120, 24, 25, 198, 208, 48, 125,
+			129, 42, 170, 191, 152, 232, 147, 116, 155, 225, 11, 84, 105, 223,
+			170, 142, 182, 158, 222, 197, 15, 223, 48, 38, 1, 66, 93, 136,
+			103, 180, 49, 112, 226, 6, 103, 99, 55, 140, 138, 232, 251, 103,
+			22, 111, 49, 88, 22, 188, 78, 190, 9, 170, 44, 228, 101, 192,
+			67, 74, 36, 207, 231, 189, 61, 188, 149, 56, 84, 22, 195, 120,
+			145, 224, 213, 101, 222, 185, 48, 94, 244, 246, 242, 182, 4, 241,
+			166, 116, 155, 110, 213, 153, 231, 243, 222, 40, 79, 161, 115, 8,
+			4, 61, 108, 62, 148, 112, 216, 53, 36, 153, 130, 135, 38, 229,
+			195, 222, 24, 223, 156, 228, 75, 145, 17, 17, 235, 248, 216, 79,
+			182, 37, 232, 82, 188, 243, 124, 27, 34, 33, 76, 135, 87, 23,
+			166, 169, 161, 32, 66, 162, 229, 208, 206, 134, 114, 104, 228, 79,
+			110, 197, 183, 198, 174, 46, 80, 214, 224, 195, 188, 93, 136, 138,
+			252, 153, 40, 169, 215, 199, 119, 93, 154, 152, 186, 52, 61, 57,
+			49, 117, 249, 194, 165, 233, 169, 75, 99, 151, 46, 79, 77, 95,
+			254, 2, 236, 137, 207, 158, 159, 56, 211, 222, 228, 165, 185, 115,
+			247, 216, 212, 84, 59, 19, 127, 157, 29, 59, 127, 161, 221, 242,
+			154, 185, 59, 62, 57, 54, 117, 174, 221, 22, 127, 194, 225, 126,
+			187, 35, 126, 159, 186, 243, 252, 221, 237, 238, 224, 183, 24, 223,
+			218, 208, 46, 222, 94, 190, 27, 106, 187, 119, 98, 242, 204, 249,
+			241, 53, 170, 219, 204, 249, 229, 47, 76, 92, 185, 123, 98, 252,
+			210, 196, 153, 118, 238, 117, 241, 237, 58, 125, 225, 254, 105, 81,
+			195, 221, 19, 103, 218, 183, 139, 154, 207, 94, 24, 187, 243, 254,
+			246, 93, 226, 165, 137, 43, 23, 191, 48, 49, 57, 38, 94, 234,
+			247, 90, 121, 90, 21, 113, 232, 38, 221, 167, 191, 219, 133, 80,
+			205, 95, 252, 228, 123, 79, 247, 107, 239, 233, 1, 185, 123, 109,
+			209, 126, 210, 226, 207, 223, 103, 220, 114, 193, 81, 122, 152, 249,
+			95, 97, 193, 148, 98, 240, 13, 235, 40, 244, 113, 255, 23, 6,
+			75, 5, 184, 194, 41, 207, 3, 76, 123, 14, 127, 207, 207, 210,
+			76, 192, 2, 16, 80, 6, 14, 227, 161, 193, 170, 101, 8, 255,
+			190, 26, 113, 96, 47, 215, 123, 209, 226, 10, 238, 42, 169, 165,
+			132, 182, 116, 193, 163, 218, 221, 193, 159, 228, 142, 11, 202, 114,
+			135, 181, 223, 175, 144, 108, 196, 181, 172, 2, 208, 49, 254, 188,
+			14, 6, 63, 156, 171, 2, 223, 139, 65, 168, 121, 2, 253, 17,
+			98, 52, 170, 146, 136, 0, 10, 211, 162, 20, 71, 50, 38, 18,
+			215, 71, 23, 53, 216, 14, 43, 160, 148, 229, 217, 59, 246, 238,
+			227, 253, 32, 28, 243, 236, 46, 171, 69, 18, 127, 65, 101, 232,
+			194, 24, 18, 130, 137, 42, 133, 193, 163, 41, 74, 89, 158, 221,
+			213, 204, 249, 247, 24, 20, 99, 121, 118, 175, 213, 66, 180, 115,
+			201, 114, 230, 67, 116, 133, 10, 166, 208, 37, 44, 214, 155, 109,
+			24, 67, 181, 82, 158, 184, 74, 164, 7, 118, 165, 2, 118, 33,
+			96, 250, 96, 175, 21, 227, 178, 80, 99, 128, 62, 205, 245, 251,
+			13, 47, 129, 235, 138, 236, 232, 249, 98, 248, 72, 164, 196, 7,
+			210, 56, 37, 62, 8, 220, 204, 249, 143, 80, 124, 219, 179, 251,
+			172, 86, 255, 165, 213, 196, 159, 171, 132, 224, 31, 151, 175, 161,
+			109, 122, 61, 154, 163, 35, 89, 32, 242, 175, 85, 129, 18, 95,
+			74, 65, 202, 233, 248, 170, 223, 183, 4, 131, 73, 168, 83, 58,
+			94, 70, 128, 31, 243, 139, 233, 187, 134, 104, 130, 169, 143, 83,
+			223, 2, 28, 115, 168, 240, 92, 116, 177, 238, 227, 45, 124, 30,
+			62, 197, 241, 236, 3, 86, 171, 127, 255, 42, 95, 2, 219, 170,
+			40, 143, 213, 136, 81, 24, 206, 150, 43, 112, 205, 132, 158, 38,
+			243, 133, 82, 33, 94, 68, 71, 147, 177, 0, 3, 243, 130, 74,
+			20, 198, 229, 210, 113, 176, 76, 202, 53, 45, 131, 195, 68, 69,
+			36, 131, 99, 121, 246, 1, 222, 194, 255, 216, 2, 33, 92, 207,
+			62, 104, 181, 248, 207, 90, 117, 82, 228, 11, 121, 73, 50, 45,
+			26, 49, 202, 169, 189, 80, 124, 156, 7, 89, 201, 176, 39, 219,
+			151, 140, 135, 164, 119, 134, 42, 43, 206, 64, 156, 51, 206, 5,
+			145, 43, 182, 50, 120, 190, 159, 73, 124, 220, 114, 37, 90, 10,
+			171, 181, 74, 84, 68, 119, 201, 68, 5, 138, 252, 26, 203, 68,
+			78, 223, 32, 144, 252, 197, 57, 18, 10, 89, 88, 194, 74, 73,
+			152, 99, 73, 68, 107, 141, 62, 147, 28, 149, 240, 189, 119, 93,
+			158, 186, 4, 61, 108, 20, 41, 91, 204, 101, 162, 141, 104, 60,
+			186, 150, 103, 31, 108, 230, 252, 33, 14, 140, 229, 71, 154, 198,
+			153, 63, 217, 184, 152, 41, 238, 243, 141, 174, 102, 146, 116, 107,
+			202, 224, 207, 115, 193, 89, 218, 237, 224, 121, 238, 184, 0, 34,
+			126, 187, 117, 192, 191, 175, 174, 10, 180, 225, 68, 95, 73, 254,
+			222, 66, 172, 88, 248, 212, 29, 51, 32, 13, 192, 130, 5, 174,
+			145, 136, 127, 39, 38, 9, 113, 252, 200, 175, 69, 223, 158, 219,
+			173, 61, 148, 178, 60, 251, 246, 125, 251, 249, 37, 144, 128, 33,
+			248, 244, 231, 117, 75, 147, 12, 139, 176, 82, 38, 88, 155, 113,
+			151, 19, 22, 139, 154, 134, 59, 193, 16, 173, 106, 100, 80, 108,
+			43, 165, 44, 207, 62, 182, 101, 43, 127, 8, 106, 180, 60, 251,
+			132, 213, 227, 223, 243, 145, 212, 88, 92, 169, 235, 91, 36, 37,
+			58, 129, 222, 158, 34, 37, 170, 219, 217, 205, 231, 160, 110, 219,
+			179, 79, 91, 109, 254, 189, 55, 81, 55, 64, 150, 148, 171, 139,
+			129, 162, 193, 134, 155, 62, 37, 1, 39, 201, 148, 0, 98, 129,
+			56, 45, 39, 39, 131, 5, 226, 52, 111, 229, 119, 128, 0, 142,
+			103, 127, 206, 242, 252, 209, 213, 5, 16, 86, 191, 52, 247, 241,
+			71, 83, 28, 85, 190, 152, 252, 159, 83, 141, 43, 38, 255, 231,
+			182, 108, 229, 147, 80, 190, 235, 217, 119, 88, 237, 254, 196, 135,
+			104, 220, 134, 206, 20, 147, 229, 14, 75, 165, 44, 207, 190, 163,
+			109, 11, 81, 231, 76, 8, 75, 135, 54, 169, 19, 233, 109, 188,
+			68, 123, 212, 115, 214, 14, 63, 12, 46, 55, 248, 244, 203, 149,
+			69, 8, 71, 163, 89, 34, 179, 224, 86, 5, 85, 106, 24, 175,
+			62, 167, 208, 0, 205, 73, 139, 61, 17, 253, 112, 46, 17, 253,
+			112, 78, 185, 245, 139, 93, 227, 185, 109, 219, 249, 109, 180, 105,
+			188, 211, 242, 125, 31, 90, 8, 8, 222, 164, 64, 210, 174, 79,
+			108, 108, 238, 76, 4, 57, 220, 217, 188, 195, 216, 216, 220, 217,
+			133, 78, 91, 160, 127, 47, 90, 221, 254, 101, 40, 242, 252, 25,
+			245, 133, 107, 178, 223, 87, 235, 59, 103, 54, 42, 150, 75, 11,
+			224, 185, 35, 38, 51, 215, 132, 250, 102, 220, 194, 197, 68, 220,
+			194, 197, 102, 147, 95, 238, 34, 48, 166, 98, 96, 130, 61, 105,
+			237, 246, 247, 160, 35, 184, 90, 196, 234, 235, 212, 81, 7, 41,
+			241, 194, 54, 35, 234, 96, 114, 187, 111, 68, 29, 76, 246, 238,
+			226, 207, 50, 25, 87, 96, 223, 107, 29, 244, 159, 2, 75, 175,
+			82, 197, 139, 7, 133, 83, 31, 33, 109, 88, 80, 169, 149, 130,
+			126, 97, 18, 209, 168, 66, 8, 67, 192, 39, 169, 40, 124, 79,
+			249, 52, 23, 143, 15, 192, 105, 125, 67, 145, 13, 164, 251, 65,
+			63, 220, 220, 94, 43, 196, 209, 64, 34, 88, 225, 94, 229, 155,
+			42, 38, 199, 189, 61, 131, 70, 176, 194, 189, 217, 28, 56, 36,
+			130, 98, 124, 192, 58, 44, 29, 42, 195, 171, 81, 5, 32, 73,
+			72, 151, 200, 74, 197, 110, 101, 226, 76, 146, 75, 189, 158, 112,
+			13, 53, 94, 125, 67, 186, 41, 81, 190, 111, 132, 27, 60, 208,
+			157, 51, 194, 13, 30, 24, 57, 164, 118, 194, 223, 121, 112, 163,
+			59, 97, 162, 31, 187, 5, 8, 129, 15, 187, 85, 190, 73, 0,
+			1, 255, 166, 233, 91, 253, 91, 57, 13, 232, 251, 21, 227, 157,
+			247, 212, 162, 202, 138, 88, 15, 206, 97, 243, 80, 88, 114, 111,
+			29, 131, 16, 134, 31, 155, 48, 5, 180, 215, 55, 162, 147, 105,
+			195, 63, 110, 240, 174, 74, 166, 246, 96, 141, 189, 186, 162, 71,
+			197, 18, 244, 123, 94, 55, 111, 94, 14, 23, 162, 233, 184, 240,
+			101, 228, 54, 117, 39, 211, 34, 99, 170, 240, 229, 200, 235, 229,
+			28, 126, 172, 150, 31, 137, 74, 176, 159, 111, 158, 132, 199, 47,
+			137, 140, 190, 107, 188, 171, 241, 195, 100, 164, 244, 97, 158, 38,
+			197, 46, 195, 211, 59, 215, 144, 109, 82, 61, 232, 29, 224, 91,
+			74, 209, 245, 234, 180, 81, 169, 228, 77, 18, 217, 119, 171, 138,
+			255, 130, 241, 158, 250, 154, 133, 217, 18, 255, 110, 180, 235, 15,
+			28, 222, 187, 198, 231, 201, 214, 253, 60, 79, 45, 84, 202, 181,
+			101, 106, 219, 131, 166, 124, 235, 190, 154, 251, 188, 120, 111, 82,
+			190, 190, 209, 22, 247, 159, 179, 185, 11, 111, 174, 114, 206, 195,
+			110, 246, 156, 103, 3, 39, 85, 3, 188, 93, 219, 44, 211, 112,
+			53, 12, 93, 225, 78, 110, 209, 249, 227, 34, 219, 59, 201, 125,
+			211, 192, 154, 150, 6, 150, 124, 9, 155, 190, 203, 124, 98, 10,
+			31, 192, 183, 119, 243, 22, 177, 247, 91, 145, 143, 187, 240, 56,
+			135, 44, 124, 96, 128, 183, 147, 17, 162, 10, 77, 161, 36, 58,
+			31, 31, 221, 207, 55, 215, 137, 188, 9, 30, 108, 75, 10, 188,
+			198, 49, 87, 250, 230, 143, 185, 250, 254, 55, 227, 219, 161, 187,
+			165, 237, 254, 209, 76, 128, 4, 85, 179, 189, 17, 170, 230, 212,
+			45, 81, 53, 127, 152, 57, 242, 223, 25, 223, 81, 247, 229, 114,
+			110, 156, 231, 105, 162, 78, 149, 179, 35, 219, 48, 59, 234, 95,
+			34, 105, 207, 151, 230, 203, 147, 234, 245, 13, 207, 142, 105, 222,
+			98, 20, 208, 48, 190, 89, 227, 248, 206, 242, 77, 50, 185, 14,
+			17, 247, 36, 61, 211, 247, 38, 227, 91, 213, 180, 222, 104, 39,
+			31, 228, 91, 101, 39, 79, 199, 181, 89, 188, 46, 51, 187, 123,
+			11, 118, 247, 20, 253, 182, 126, 191, 127, 152, 206, 186, 143, 123,
+			166, 244, 178, 163, 118, 242, 180, 148, 143, 216, 109, 54, 161, 68,
+			27, 110, 248, 67, 255, 67, 30, 142, 203, 149, 206, 187, 196, 93,
+			168, 40, 201, 159, 183, 134, 246, 245, 247, 173, 255, 144, 194, 213,
+			152, 227, 28, 126, 133, 133, 212, 235, 223, 192, 90, 139, 229, 15,
+			108, 120, 85, 238, 107, 242, 238, 229, 109, 137, 161, 233, 5, 235,
+			140, 90, 44, 127, 207, 13, 199, 117, 95, 147, 119, 151, 20, 30,
+			218, 62, 137, 237, 209, 48, 162, 252, 93, 107, 253, 124, 139, 232,
+			28, 239, 126, 30, 209, 57, 254, 246, 119, 11, 157, 163, 127, 13,
+			116, 14, 241, 167, 227, 217, 173, 77, 199, 225, 79, 215, 179, 219,
+			154, 78, 201, 179, 232, 45, 250, 44, 90, 252, 57, 138, 152, 29,
+			94, 211, 1, 230, 15, 16, 102, 135, 9, 217, 81, 137, 194, 188,
+			201, 232, 91, 136, 76, 12, 14, 47, 189, 141, 255, 144, 73, 16,
+			14, 167, 195, 234, 12, 252, 63, 100, 6, 12, 71, 146, 205, 25,
+			137, 2, 37, 152, 176, 248, 229, 252, 25, 137, 15, 15, 89, 132,
+			127, 16, 98, 115, 132, 92, 230, 39, 72, 143, 115, 60, 24, 155,
+			67, 175, 169, 48, 65, 80, 173, 227, 200, 129, 16, 15, 169, 156,
+			97, 135, 154, 56, 216, 145, 192, 30, 29, 169, 22, 3, 216, 163,
+			163, 117, 175, 1, 236, 209, 233, 244, 64, 76, 7, 0, 123, 56,
+			61, 86, 239, 126, 255, 7, 44, 129, 45, 18, 215, 150, 150, 194,
+			202, 138, 230, 200, 190, 185, 47, 228, 234, 19, 87, 251, 194, 248,
+			35, 248, 68, 177, 239, 238, 73, 181, 27, 128, 30, 61, 91, 179,
+			6, 160, 71, 175, 179, 135, 63, 74, 128, 30, 123, 172, 11, 126,
+			222, 248, 62, 197, 248, 189, 193, 254, 130, 209, 124, 45, 170, 0,
+			158, 221, 92, 185, 98, 108, 249, 150, 195, 184, 26, 72, 74, 233,
+			4, 214, 199, 158, 212, 54, 3, 235, 99, 207, 246, 126, 3, 235,
+			99, 207, 225, 115, 252, 203, 132, 245, 177, 223, 26, 247, 151, 130,
+			179, 133, 82, 62, 38, 25, 228, 214, 88, 211, 83, 68, 82, 32,
+			165, 94, 214, 146, 146, 131, 152, 27, 147, 82, 76, 177, 253, 170,
+			9, 197, 44, 219, 191, 181, 207, 192, 4, 217, 159, 253, 44, 63,
+			137, 167, 57, 3, 77, 19, 204, 31, 54, 40, 26, 53, 153, 73,
+			37, 152, 49, 22, 91, 92, 203, 102, 16, 212, 66, 157, 255, 12,
+			164, 119, 243, 63, 37, 39, 5, 39, 107, 229, 178, 254, 15, 197,
+			120, 67, 164, 215, 92, 35, 222, 132, 121, 46, 161, 142, 209, 130,
+			243, 185, 8, 239, 196, 67, 130, 170, 198, 219, 25, 58, 62, 168,
+			106, 18, 251, 76, 80, 168, 66, 224, 119, 44, 159, 145, 109, 9,
+			32, 166, 149, 176, 132, 119, 239, 197, 21, 73, 113, 98, 28, 196,
+			16, 138, 98, 146, 142, 14, 79, 146, 178, 137, 147, 164, 108, 226,
+			36, 41, 171, 0, 34, 154, 210, 158, 157, 83, 216, 7, 50, 172,
+			48, 231, 14, 105, 152, 141, 17, 235, 80, 214, 63, 84, 215, 0,
+			52, 254, 214, 225, 228, 55, 78, 160, 70, 18, 39, 80, 35, 9,
+			152, 141, 145, 4, 204, 198, 33, 37, 138, 132, 217, 56, 228, 14,
+			241, 203, 4, 179, 49, 106, 29, 205, 2, 174, 16, 137, 50, 150,
+			60, 129, 170, 163, 146, 199, 19, 104, 130, 187, 149, 13, 69, 123,
+			119, 117, 38, 149, 242, 236, 81, 60, 87, 150, 103, 82, 163, 125,
+			67, 198, 153, 212, 104, 238, 160, 129, 165, 113, 84, 201, 39, 177,
+			52, 142, 186, 67, 252, 191, 49, 58, 179, 58, 101, 117, 248, 255,
+			17, 175, 128, 150, 194, 235, 133, 165, 218, 146, 225, 69, 20, 149,
+			196, 148, 150, 203, 120, 181, 86, 41, 81, 20, 95, 18, 94, 25,
+			224, 36, 231, 163, 107, 154, 22, 190, 32, 1, 192, 49, 86, 173,
+			86, 50, 233, 240, 171, 193, 82, 57, 174, 6, 35, 195, 195, 122,
+			161, 104, 140, 45, 75, 72, 36, 1, 192, 99, 112, 92, 57, 65,
+			206, 168, 24, 65, 33, 178, 116, 64, 102, 57, 170, 204, 161, 167,
+			171, 200, 175, 131, 248, 216, 100, 66, 124, 164, 183, 154, 16, 31,
+			219, 119, 240, 255, 172, 14, 219, 206, 88, 93, 254, 191, 103, 193,
+			24, 18, 112, 131, 217, 150, 17, 115, 62, 82, 113, 1, 120, 148,
+			6, 100, 52, 193, 92, 88, 44, 230, 184, 82, 125, 240, 241, 70,
+			148, 176, 116, 232, 154, 141, 197, 212, 70, 8, 39, 240, 68, 4,
+			34, 30, 194, 115, 42, 45, 100, 224, 64, 24, 35, 36, 150, 195,
+			74, 184, 20, 85, 163, 138, 17, 34, 33, 195, 164, 132, 217, 8,
+			53, 194, 53, 11, 55, 163, 252, 68, 38, 172, 107, 70, 4, 75,
+			100, 124, 130, 62, 185, 115, 197, 39, 166, 141, 147, 187, 51, 205,
+			219, 140, 147, 187, 51, 29, 157, 252, 20, 198, 72, 159, 107, 154,
+			100, 254, 136, 201, 231, 186, 241, 149, 73, 76, 156, 115, 233, 128,
+			63, 195, 136, 184, 245, 130, 117, 208, 127, 50, 17, 57, 153, 80,
+			126, 162, 211, 19, 202, 144, 122, 21, 112, 160, 209, 24, 154, 73,
+			238, 217, 103, 130, 51, 19, 83, 227, 153, 96, 198, 220, 167, 204,
+			4, 99, 83, 227, 25, 30, 204, 36, 124, 68, 32, 55, 65, 9,
+			123, 33, 65, 9, 123, 161, 101, 143, 17, 147, 125, 161, 111, 208,
+			136, 201, 190, 144, 205, 193, 165, 175, 3, 23, 55, 247, 88, 189,
+			120, 233, 75, 225, 251, 9, 126, 30, 228, 7, 144, 124, 182, 112,
+			165, 160, 206, 99, 245, 44, 10, 46, 149, 1, 63, 92, 117, 41,
+			81, 189, 231, 195, 106, 152, 9, 226, 72, 198, 216, 192, 201, 60,
+			41, 5, 0, 92, 33, 214, 23, 56, 25, 149, 224, 95, 102, 247,
+			50, 88, 190, 238, 81, 1, 230, 162, 23, 238, 145, 112, 54, 12,
+			150, 175, 123, 186, 123, 248, 24, 70, 144, 95, 110, 122, 148, 249,
+			163, 27, 214, 59, 96, 234, 155, 93, 44, 90, 227, 114, 122, 31,
+			42, 31, 136, 60, 191, 223, 122, 224, 19, 167, 124, 48, 166, 253,
+			254, 68, 76, 251, 253, 114, 197, 199, 152, 246, 251, 229, 138, 143,
+			49, 237, 15, 212, 199, 180, 63, 32, 149, 15, 132, 190, 126, 201,
+			122, 232, 214, 149, 143, 5, 189, 247, 37, 37, 138, 232, 189, 47,
+			41, 81, 68, 239, 125, 73, 137, 34, 148, 207, 67, 74, 20, 169,
+			124, 30, 146, 202, 71, 88, 69, 78, 104, 205, 126, 196, 202, 199,
+			2, 229, 19, 74, 229, 99, 129, 242, 9, 165, 242, 177, 64, 249,
+			132, 82, 249, 88, 160, 124, 102, 149, 124, 82, 249, 204, 146, 242,
+			129, 135, 11, 255, 15, 40, 31, 11, 148, 79, 65, 42, 31, 11,
+			148, 79, 65, 42, 31, 11, 148, 79, 129, 148, 143, 37, 148, 207,
+			242, 39, 64, 249, 112, 188, 228, 191, 121, 229, 99, 129, 242, 89,
+			86, 227, 91, 40, 159, 229, 102, 2, 140, 16, 202, 103, 185, 163,
+			19, 0, 229, 108, 207, 173, 54, 253, 33, 99, 254, 209, 141, 107,
+			159, 134, 245, 73, 180, 117, 53, 189, 159, 183, 73, 54, 108, 183,
+			102, 125, 155, 153, 116, 216, 53, 190, 133, 191, 192, 52, 31, 246,
+			138, 51, 226, 127, 147, 209, 21, 94, 226, 94, 76, 236, 202, 43,
+			229, 218, 114, 131, 150, 50, 175, 217, 64, 51, 233, 125, 193, 161,
+			35, 193, 98, 185, 86, 137, 141, 88, 73, 185, 98, 171, 64, 2,
+			112, 235, 13, 151, 171, 181, 10, 189, 166, 43, 210, 52, 218, 41,
+			33, 90, 111, 130, 102, 123, 101, 87, 38, 65, 179, 189, 114, 112,
+			152, 15, 104, 154, 237, 199, 157, 158, 117, 47, 91, 77, 210, 236,
+			199, 235, 72, 179, 31, 111, 233, 76, 144, 102, 63, 238, 119, 243,
+			9, 89, 180, 229, 217, 79, 58, 187, 253, 163, 136, 142, 162, 38,
+			171, 62, 151, 174, 219, 208, 210, 254, 170, 238, 139, 44, 87, 148,
+			211, 172, 211, 204, 179, 159, 228, 190, 78, 219, 158, 253, 100, 239,
+			46, 126, 183, 172, 214, 246, 156, 175, 50, 103, 192, 255, 220, 154,
+			245, 106, 151, 132, 245, 37, 216, 34, 107, 176, 93, 40, 178, 89,
+			103, 48, 145, 193, 247, 233, 12, 168, 244, 182, 126, 69, 152, 238,
+			120, 206, 83, 204, 241, 253, 145, 58, 33, 224, 148, 125, 99, 181,
+			58, 46, 148, 161, 107, 21, 86, 214, 83, 140, 239, 208, 25, 182,
+			200, 232, 218, 201, 207, 202, 90, 93, 64, 2, 105, 108, 114, 125,
+			106, 191, 177, 170, 93, 44, 72, 87, 237, 50, 145, 193, 125, 157,
+			97, 139, 140, 222, 93, 252, 140, 172, 26, 192, 67, 156, 94, 255,
+			72, 67, 213, 27, 233, 107, 42, 54, 229, 66, 49, 186, 226, 20,
+			160, 144, 240, 46, 157, 1, 56, 36, 221, 61, 252, 130, 172, 120,
+			147, 231, 252, 62, 115, 70, 253, 147, 107, 222, 25, 47, 135, 49,
+			112, 253, 215, 93, 24, 175, 42, 192, 166, 20, 20, 215, 163, 51,
+			152, 200, 232, 29, 214, 25, 182, 200, 56, 124, 132, 255, 255, 196,
+			13, 239, 60, 203, 172, 30, 255, 177, 53, 173, 85, 172, 36, 206,
+			173, 97, 178, 194, 175, 210, 105, 69, 154, 175, 188, 206, 126, 205,
+			135, 213, 117, 236, 215, 4, 226, 205, 179, 73, 196, 155, 103, 89,
+			75, 187, 129, 120, 243, 44, 219, 218, 105, 32, 222, 60, 203, 252,
+			110, 254, 199, 76, 114, 198, 59, 47, 176, 191, 175, 230, 42, 138,
+			204, 92, 144, 49, 77, 73, 16, 185, 185, 139, 146, 128, 145, 210,
+			221, 195, 79, 35, 132, 207, 119, 89, 211, 191, 96, 107, 159, 149,
+			128, 159, 125, 226, 4, 153, 212, 1, 129, 254, 124, 151, 165, 123,
+			248, 57, 2, 253, 121, 145, 89, 47, 177, 172, 255, 153, 213, 236,
+			85, 117, 238, 99, 218, 108, 250, 104, 11, 141, 54, 13, 237, 243,
+			98, 18, 218, 231, 69, 214, 220, 110, 64, 251, 188, 200, 182, 109,
+			55, 160, 125, 94, 106, 128, 246, 121, 137, 185, 67, 124, 156, 160,
+			125, 94, 102, 214, 43, 44, 235, 31, 222, 136, 25, 185, 186, 72,
+			162, 89, 95, 214, 34, 137, 102, 125, 89, 139, 36, 154, 245, 101,
+			45, 18, 75, 123, 206, 43, 13, 192, 63, 175, 8, 145, 126, 205,
+			64, 38, 203, 115, 94, 99, 86, 167, 255, 111, 88, 112, 113, 25,
+			3, 134, 80, 36, 217, 74, 217, 120, 174, 188, 12, 164, 92, 97,
+			113, 105, 93, 249, 148, 35, 29, 154, 158, 226, 113, 138, 170, 23,
+			57, 125, 39, 101, 137, 167, 143, 247, 9, 51, 103, 190, 112, 93,
+			25, 44, 134, 33, 7, 94, 120, 218, 183, 70, 31, 56, 26, 71,
+			225, 96, 158, 32, 127, 31, 214, 34, 169, 37, 235, 0, 108, 28,
+			0, 176, 121, 77, 183, 149, 152, 53, 175, 177, 102, 143, 146, 182,
+			72, 238, 232, 224, 51, 208, 18, 182, 231, 252, 152, 89, 3, 254,
+			164, 209, 16, 171, 74, 71, 103, 174, 136, 5, 23, 215, 25, 212,
+			235, 136, 99, 167, 160, 10, 170, 95, 232, 165, 31, 179, 109, 251,
+			40, 9, 2, 220, 214, 207, 223, 195, 158, 113, 60, 231, 39, 204,
+			234, 240, 223, 93, 203, 142, 86, 141, 243, 9, 54, 164, 241, 211,
+			133, 242, 252, 9, 179, 54, 81, 146, 137, 100, 122, 43, 37, 109,
+			145, 220, 190, 131, 255, 47, 108, 24, 215, 115, 222, 100, 86, 151,
+			255, 95, 54, 108, 76, 215, 175, 28, 31, 155, 113, 93, 95, 241,
+			173, 156, 237, 96, 35, 184, 248, 213, 52, 148, 133, 138, 127, 147,
+			53, 19, 170, 152, 80, 240, 111, 178, 142, 78, 254, 89, 132, 243,
+			250, 41, 107, 250, 119, 108, 157, 3, 158, 245, 151, 83, 81, 248,
+			79, 89, 186, 151, 127, 150, 59, 142, 107, 55, 121, 169, 159, 49,
+			235, 95, 51, 219, 63, 24, 140, 227, 142, 60, 54, 23, 0, 131,
+			11, 86, 70, 142, 131, 7, 226, 34, 225, 72, 9, 131, 220, 249,
+			153, 48, 133, 6, 121, 74, 36, 133, 22, 126, 139, 221, 192, 142,
+			21, 26, 28, 158, 117, 225, 97, 35, 131, 137, 140, 150, 78, 157,
+			97, 139, 12, 191, 155, 15, 203, 226, 153, 231, 252, 43, 230, 236,
+			149, 168, 163, 134, 116, 107, 86, 194, 82, 240, 74, 135, 206, 128,
+			50, 58, 119, 233, 12, 91, 100, 236, 233, 227, 39, 37, 40, 154,
+			243, 54, 179, 246, 249, 98, 235, 77, 86, 4, 77, 151, 92, 112,
+			81, 159, 107, 81, 27, 153, 13, 2, 170, 255, 109, 82, 253, 8,
+			101, 246, 54, 107, 233, 48, 160, 204, 222, 102, 157, 187, 13, 40,
+			179, 183, 89, 223, 94, 254, 42, 35, 168, 178, 119, 132, 234, 255,
+			142, 24, 254, 114, 228, 75, 108, 83, 140, 215, 7, 192, 198, 48,
+			22, 38, 9, 93, 78, 207, 52, 12, 111, 165, 237, 113, 13, 208,
+			144, 149, 224, 10, 141, 144, 57, 25, 105, 65, 192, 213, 72, 169,
+			92, 63, 37, 98, 162, 132, 150, 123, 41, 69, 90, 164, 97, 210,
+			92, 144, 53, 109, 192, 164, 189, 67, 38, 0, 194, 164, 189, 35,
+			76, 128, 19, 8, 32, 246, 11, 214, 244, 63, 25, 243, 179, 55,
+			52, 1, 224, 202, 215, 28, 176, 194, 238, 252, 5, 75, 239, 228,
+			19, 132, 55, 246, 75, 102, 253, 138, 101, 253, 209, 13, 235, 255,
+			42, 196, 114, 24, 154, 22, 161, 196, 126, 153, 132, 18, 251, 37,
+			105, 90, 132, 18, 251, 37, 105, 90, 132, 18, 251, 85, 3, 148,
+			216, 175, 132, 166, 157, 34, 36, 177, 119, 153, 245, 107, 150, 245,
+			199, 13, 161, 46, 130, 186, 211, 113, 36, 230, 213, 149, 190, 180,
+			90, 3, 161, 13, 225, 190, 222, 77, 194, 125, 189, 203, 154, 125,
+			3, 238, 235, 93, 97, 246, 107, 184, 175, 95, 55, 192, 125, 253,
+			90, 136, 248, 103, 136, 118, 102, 121, 206, 111, 132, 49, 240, 214,
+			223, 71, 99, 224, 214, 140, 128, 20, 24, 1, 191, 209, 109, 36,
+			230, 207, 111, 200, 8, 72, 129, 17, 240, 27, 97, 4, 188, 135,
+			77, 96, 123, 206, 123, 235, 106, 93, 125, 213, 248, 119, 164, 117,
+			85, 5, 127, 183, 90, 55, 5, 27, 229, 247, 72, 235, 166, 192,
+			28, 121, 143, 180, 110, 10, 204, 145, 247, 132, 214, 253, 75, 108,
+			24, 199, 115, 222, 23, 90, 247, 63, 221, 164, 214, 149, 147, 245,
+			99, 86, 185, 178, 214, 91, 213, 183, 41, 48, 73, 222, 215, 163,
+			70, 152, 36, 239, 147, 190, 77, 129, 73, 242, 190, 208, 183, 39,
+			17, 39, 239, 175, 89, 211, 223, 50, 6, 218, 224, 6, 250, 182,
+			97, 237, 18, 91, 214, 191, 102, 105, 159, 159, 32, 92, 189, 15,
+			152, 181, 27, 86, 193, 196, 246, 244, 124, 222, 80, 44, 97, 113,
+			121, 49, 156, 141, 170, 5, 241, 65, 43, 9, 160, 188, 15, 146,
+			64, 121, 31, 36, 129, 242, 62, 96, 158, 111, 0, 229, 125, 32,
+			86, 7, 208, 43, 155, 196, 188, 248, 237, 39, 66, 175, 108, 130,
+			101, 239, 183, 212, 57, 155, 96, 217, 251, 45, 233, 149, 77, 176,
+			236, 253, 150, 117, 247, 40, 231, 240, 159, 78, 242, 35, 27, 116,
+			140, 38, 21, 190, 182, 119, 248, 71, 229, 220, 61, 114, 227, 199,
+			1, 131, 15, 104, 249, 209, 81, 251, 27, 140, 247, 169, 65, 36,
+			13, 183, 179, 8, 68, 61, 25, 86, 21, 181, 85, 87, 157, 215,
+			157, 118, 184, 59, 203, 219, 18, 159, 9, 44, 81, 45, 135, 246,
+			52, 56, 15, 75, 95, 65, 21, 100, 50, 217, 90, 213, 217, 113,
+			223, 87, 25, 223, 177, 234, 115, 107, 7, 127, 223, 156, 63, 97,
+			131, 135, 162, 221, 224, 161, 216, 247, 190, 197, 247, 174, 219, 26,
+			210, 139, 239, 94, 222, 12, 144, 134, 87, 195, 34, 249, 91, 126,
+			102, 85, 47, 178, 181, 203, 200, 157, 151, 5, 76, 234, 162, 188,
+			139, 171, 55, 230, 224, 26, 141, 105, 20, 59, 86, 10, 139, 43,
+			113, 33, 78, 182, 170, 255, 18, 227, 105, 170, 72, 52, 0, 85,
+			53, 29, 46, 160, 15, 179, 59, 217, 66, 121, 99, 11, 145, 119,
+			140, 115, 56, 203, 70, 39, 231, 181, 104, 201, 180, 147, 115, 51,
+			60, 13, 14, 206, 163, 60, 29, 149, 242, 248, 162, 125, 195, 23,
+			55, 69, 165, 188, 72, 245, 253, 85, 154, 239, 90, 255, 147, 62,
+			198, 1, 224, 77, 243, 205, 170, 137, 226, 106, 88, 141, 187, 156,
+			198, 222, 93, 95, 92, 213, 177, 232, 236, 216, 86, 48, 147, 222,
+			85, 238, 87, 106, 165, 105, 116, 191, 150, 39, 129, 211, 18, 64,
+			37, 238, 114, 161, 178, 99, 55, 81, 153, 244, 201, 151, 81, 162,
+			147, 157, 149, 90, 233, 172, 40, 59, 153, 31, 123, 33, 223, 34,
+			148, 106, 73, 5, 106, 196, 93, 169, 155, 254, 178, 73, 40, 129,
+			66, 24, 54, 87, 204, 100, 236, 255, 37, 227, 109, 137, 111, 223,
+			200, 128, 59, 197, 187, 171, 229, 106, 88, 156, 22, 173, 162, 28,
+			201, 149, 140, 136, 130, 218, 5, 143, 76, 214, 74, 19, 242, 1,
+			170, 211, 187, 157, 119, 233, 215, 19, 141, 26, 75, 215, 249, 29,
+			244, 174, 217, 44, 177, 55, 198, 123, 245, 139, 134, 215, 189, 122,
+			27, 189, 125, 125, 122, 251, 178, 122, 68, 125, 239, 207, 25, 223,
+			156, 108, 230, 143, 34, 78, 224, 8, 239, 160, 192, 178, 233, 36,
+			110, 5, 58, 3, 111, 167, 95, 207, 155, 248, 21, 159, 225, 45,
+			122, 105, 23, 159, 46, 186, 182, 35, 65, 136, 167, 126, 158, 52,
+			31, 245, 255, 138, 241, 182, 68, 191, 126, 2, 63, 194, 203, 241,
+			109, 139, 97, 108, 246, 99, 165, 86, 194, 62, 76, 79, 110, 93,
+			12, 99, 221, 125, 147, 181, 82, 124, 232, 41, 198, 91, 141, 177,
+			30, 123, 53, 222, 14, 107, 182, 49, 234, 189, 220, 134, 87, 116,
+			244, 35, 94, 61, 30, 101, 109, 13, 112, 211, 142, 197, 111, 141,
+			161, 99, 241, 47, 62, 185, 142, 197, 232, 22, 156, 214, 110, 193,
+			242, 79, 187, 73, 251, 21, 51, 237, 87, 108, 121, 118, 107, 211,
+			9, 126, 14, 253, 134, 183, 52, 117, 51, 255, 228, 170, 92, 127,
+			229, 89, 216, 226, 138, 37, 187, 16, 87, 11, 115, 176, 105, 17,
+			27, 67, 243, 196, 215, 112, 37, 222, 146, 222, 206, 95, 181, 201,
+			149, 120, 167, 227, 127, 198, 255, 142, 29, 136, 78, 43, 72, 190,
+			125, 162, 179, 170, 0, 77, 253, 188, 25, 198, 110, 150, 153, 145,
+			91, 169, 66, 105, 129, 3, 130, 79, 88, 4, 40, 70, 130, 225,
+			42, 204, 55, 156, 60, 139, 166, 197, 91, 64, 132, 186, 229, 154,
+			50, 39, 150, 214, 55, 212, 94, 40, 45, 228, 2, 177, 160, 150,
+			242, 146, 167, 190, 22, 71, 162, 47, 42, 209, 92, 97, 25, 156,
+			29, 120, 80, 64, 130, 54, 35, 100, 56, 200, 71, 115, 133, 152,
+			184, 223, 47, 93, 60, 115, 177, 127, 174, 50, 91, 91, 0, 144,
+			228, 145, 195, 35, 71, 70, 142, 29, 25, 56, 174, 54, 211, 192,
+			12, 191, 28, 85, 10, 128, 192, 91, 68, 158, 63, 226, 16, 196,
+			122, 57, 14, 166, 40, 24, 191, 39, 81, 17, 28, 204, 197, 194,
+			212, 166, 179, 15, 9, 241, 12, 113, 165, 75, 229, 171, 97, 81,
+			136, 48, 46, 105, 1, 137, 131, 77, 212, 32, 129, 182, 128, 180,
+			104, 101, 93, 10, 194, 67, 117, 36, 137, 59, 211, 93, 134, 47,
+			245, 206, 157, 39, 12, 95, 106, 127, 235, 17, 138, 121, 238, 109,
+			218, 175, 99, 158, 123, 211, 131, 252, 2, 197, 60, 239, 118, 58,
+			253, 207, 174, 75, 171, 150, 184, 37, 208, 144, 96, 197, 114, 249,
+			145, 40, 31, 192, 109, 161, 246, 67, 221, 237, 112, 195, 15, 117,
+			119, 139, 103, 248, 161, 238, 222, 209, 129, 120, 253, 112, 203, 190,
+			207, 25, 245, 127, 200, 86, 185, 36, 76, 158, 185, 227, 190, 135,
+			46, 214, 230, 203, 149, 28, 15, 198, 234, 247, 242, 234, 29, 34,
+			84, 81, 163, 19, 105, 222, 245, 246, 134, 128, 16, 74, 18, 153,
+			31, 42, 193, 147, 48, 117, 11, 151, 24, 157, 213, 107, 133, 57,
+			3, 58, 202, 17, 130, 183, 82, 42, 229, 217, 251, 218, 250, 12,
+			111, 215, 125, 123, 135, 13, 111, 215, 125, 135, 143, 240, 44, 122,
+			3, 246, 55, 141, 50, 25, 224, 108, 130, 215, 39, 125, 204, 13,
+			239, 191, 254, 116, 47, 31, 35, 231, 191, 65, 167, 211, 63, 18,
+			140, 173, 194, 54, 103, 58, 132, 129, 223, 181, 121, 252, 150, 224,
+			77, 25, 116, 184, 225, 163, 55, 40, 59, 6, 125, 244, 6, 119,
+			116, 240, 171, 228, 162, 151, 115, 186, 252, 66, 112, 38, 138, 231,
+			42, 133, 101, 58, 211, 5, 112, 47, 2, 110, 190, 22, 174, 32,
+			241, 73, 169, 68, 196, 62, 66, 128, 12, 71, 38, 193, 80, 63,
+			137, 145, 208, 25, 12, 133, 142, 42, 18, 95, 12, 164, 141, 107,
+			5, 229, 26, 197, 160, 37, 115, 78, 139, 225, 120, 151, 107, 221,
+			102, 56, 222, 229, 58, 58, 249, 59, 232, 69, 104, 121, 246, 17,
+			167, 199, 255, 25, 14, 157, 196, 225, 111, 48, 86, 172, 70, 149,
+			18, 2, 150, 85, 203, 82, 142, 21, 18, 82, 181, 114, 112, 183,
+			162, 65, 148, 143, 224, 26, 87, 43, 22, 85, 129, 64, 49, 118,
+			53, 44, 20, 195, 217, 98, 52, 144, 145, 72, 100, 60, 81, 33,
+			172, 104, 73, 62, 122, 28, 91, 117, 153, 249, 168, 26, 22, 138,
+			92, 81, 254, 224, 186, 160, 62, 222, 114, 197, 39, 169, 20, 243,
+			236, 35, 45, 157, 148, 178, 61, 251, 136, 223, 77, 188, 53, 183,
+			55, 133, 154, 183, 230, 246, 244, 16, 191, 200, 29, 71, 168, 11,
+			231, 184, 51, 225, 250, 99, 1, 89, 156, 146, 0, 49, 214, 187,
+			124, 236, 11, 73, 23, 2, 39, 38, 34, 151, 140, 79, 14, 247,
+			209, 228, 106, 4, 14, 62, 199, 91, 183, 243, 111, 49, 158, 18,
+			73, 49, 10, 79, 167, 119, 251, 143, 227, 8, 166, 74, 144, 23,
+			144, 184, 22, 131, 112, 33, 58, 53, 66, 71, 146, 48, 67, 209,
+			38, 230, 234, 141, 12, 60, 115, 136, 158, 81, 5, 21, 150, 150,
+			162, 124, 1, 225, 108, 37, 186, 140, 80, 167, 25, 108, 179, 184,
+			28, 0, 144, 229, 102, 190, 9, 133, 113, 133, 52, 109, 58, 205,
+			60, 251, 244, 102, 95, 167, 109, 207, 62, 221, 187, 139, 159, 148,
+			194, 51, 207, 30, 75, 143, 248, 217, 85, 157, 147, 12, 41, 250,
+			21, 86, 220, 128, 81, 155, 24, 155, 99, 233, 61, 58, 45, 138,
+			235, 203, 232, 180, 237, 217, 99, 7, 135, 249, 113, 89, 155, 229,
+			217, 103, 210, 7, 253, 33, 132, 167, 41, 229, 215, 168, 43, 186,
+			190, 74, 93, 86, 74, 188, 172, 235, 18, 162, 159, 233, 27, 212,
+			105, 219, 179, 207, 100, 115, 252, 123, 105, 233, 53, 106, 63, 232,
+			236, 243, 255, 32, 173, 137, 192, 214, 239, 102, 234, 229, 96, 28,
+			137, 77, 138, 43, 65, 20, 206, 45, 234, 223, 13, 8, 231, 67,
+			71, 130, 107, 81, 244, 72, 62, 92, 65, 79, 172, 140, 244, 69,
+			144, 211, 137, 195, 175, 17, 66, 41, 163, 15, 169, 2, 70, 198,
+			152, 26, 197, 210, 167, 143, 170, 203, 160, 227, 162, 82, 140, 20,
+			195, 242, 220, 174, 186, 178, 44, 21, 189, 98, 233, 139, 98, 73,
+			26, 22, 194, 122, 29, 71, 81, 41, 16, 246, 23, 85, 26, 231,
+			120, 48, 85, 214, 114, 35, 166, 161, 57, 254, 128, 4, 77, 121,
+			145, 149, 231, 213, 199, 136, 38, 224, 122, 148, 25, 4, 106, 120,
+			214, 94, 16, 106, 36, 31, 213, 13, 85, 163, 36, 158, 40, 42,
+			49, 94, 205, 225, 26, 156, 47, 5, 176, 95, 82, 135, 113, 242,
+			180, 120, 84, 137, 29, 103, 228, 177, 119, 148, 15, 70, 68, 235,
+			140, 138, 190, 89, 75, 104, 170, 233, 138, 65, 112, 138, 110, 188,
+			146, 126, 112, 41, 44, 22, 197, 218, 42, 236, 151, 114, 94, 140,
+			61, 160, 237, 172, 6, 87, 164, 46, 151, 206, 36, 113, 16, 93,
+			15, 231, 68, 231, 31, 58, 194, 117, 93, 161, 170, 173, 80, 10,
+			46, 95, 26, 135, 139, 137, 10, 212, 121, 28, 142, 26, 175, 32,
+			136, 143, 124, 80, 116, 125, 63, 62, 57, 144, 145, 183, 16, 120,
+			140, 142, 210, 138, 207, 229, 193, 28, 244, 26, 156, 41, 227, 12,
+			160, 87, 107, 203, 66, 246, 43, 10, 208, 165, 60, 175, 207, 181,
+			207, 86, 10, 66, 12, 42, 61, 87, 87, 251, 93, 229, 146, 249,
+			243, 90, 149, 235, 186, 121, 93, 229, 25, 89, 123, 40, 231, 102,
+			137, 106, 148, 55, 68, 68, 178, 130, 70, 229, 149, 219, 98, 245,
+			28, 85, 141, 156, 185, 82, 40, 153, 25, 86, 131, 207, 132, 75,
+			40, 141, 236, 130, 133, 114, 20, 243, 96, 54, 156, 3, 50, 38,
+			89, 9, 62, 39, 190, 234, 34, 97, 131, 100, 140, 175, 187, 84,
+			139, 98, 144, 165, 92, 223, 14, 102, 209, 220, 252, 196, 134, 241,
+			174, 221, 179, 29, 177, 68, 180, 82, 42, 229, 217, 15, 182, 237,
+			48, 156, 181, 31, 236, 216, 109, 56, 107, 63, 216, 183, 151, 63,
+			33, 189, 177, 237, 25, 231, 180, 255, 168, 129, 202, 35, 21, 95,
+			98, 39, 16, 202, 179, 16, 205, 65, 163, 124, 60, 201, 36, 67,
+			244, 81, 217, 55, 114, 67, 4, 110, 93, 226, 175, 21, 10, 10,
+			83, 111, 147, 135, 182, 35, 4, 32, 185, 197, 18, 60, 211, 54,
+			100, 120, 118, 207, 100, 142, 25, 158, 221, 51, 39, 79, 241, 2,
+			122, 190, 206, 139, 253, 160, 255, 32, 224, 171, 138, 205, 71, 37,
+			42, 70, 87, 193, 144, 43, 175, 202, 197, 89, 23, 229, 103, 194,
+			242, 106, 39, 193, 66, 73, 195, 191, 24, 254, 177, 243, 233, 3,
+			96, 164, 217, 98, 33, 94, 252, 80, 70, 154, 13, 74, 109, 209,
+			81, 41, 230, 217, 139, 210, 72, 3, 223, 52, 123, 113, 71, 7,
+			255, 62, 185, 166, 217, 75, 78, 151, 255, 15, 217, 199, 102, 166,
+			225, 45, 234, 114, 25, 9, 228, 243, 98, 228, 235, 68, 153, 124,
+			225, 233, 10, 1, 133, 22, 157, 182, 36, 109, 58, 112, 78, 179,
+			151, 164, 77, 7, 190, 105, 246, 82, 71, 39, 191, 11, 190, 199,
+			242, 236, 71, 157, 30, 233, 160, 154, 188, 206, 191, 149, 138, 133,
+			61, 245, 168, 106, 73, 209, 90, 143, 74, 123, 202, 6, 5, 250,
+			168, 223, 173, 221, 154, 99, 231, 247, 152, 107, 184, 53, 199, 173,
+			59, 249, 243, 150, 225, 214, 156, 222, 237, 63, 133, 184, 116, 210,
+			113, 207, 84, 227, 185, 96, 53, 123, 71, 155, 59, 60, 216, 184,
+			173, 147, 208, 29, 227, 149, 114, 28, 35, 91, 55, 112, 11, 16,
+			205, 139, 86, 29, 242, 206, 7, 219, 128, 7, 27, 56, 97, 209,
+			23, 103, 176, 85, 54, 233, 226, 64, 31, 212, 153, 10, 200, 191,
+			171, 236, 1, 211, 167, 218, 21, 237, 210, 150, 244, 169, 222, 236,
+			39, 125, 170, 123, 119, 241, 127, 206, 180, 83, 245, 19, 233, 156,
+			255, 42, 171, 115, 134, 213, 190, 159, 160, 158, 22, 195, 60, 94,
+			72, 43, 39, 217, 74, 173, 4, 33, 185, 165, 68, 22, 66, 217,
+			1, 104, 19, 12, 229, 248, 90, 40, 166, 245, 66, 80, 13, 227,
+			71, 6, 84, 97, 112, 203, 85, 140, 194, 184, 202, 97, 102, 232,
+			34, 96, 55, 153, 9, 192, 4, 3, 29, 73, 238, 207, 26, 24,
+			205, 116, 242, 126, 194, 248, 86, 49, 142, 159, 216, 60, 144, 112,
+			242, 126, 34, 147, 229, 127, 106, 41, 47, 111, 231, 27, 44, 61,
+			228, 255, 35, 235, 198, 31, 219, 136, 210, 198, 77, 119, 240, 143,
+			243, 235, 101, 69, 201, 218, 117, 85, 117, 29, 180, 10, 152, 92,
+			208, 31, 34, 114, 53, 212, 9, 85, 154, 133, 193, 67, 3, 171,
+			212, 170, 28, 137, 45, 23, 90, 174, 77, 103, 48, 145, 177, 249,
+			128, 206, 176, 69, 198, 192, 32, 255, 15, 76, 251, 182, 63, 195,
+			210, 195, 254, 91, 27, 29, 89, 171, 182, 238, 90, 31, 189, 110,
+			251, 98, 129, 188, 241, 35, 55, 216, 18, 124, 157, 166, 176, 93,
+			248, 174, 182, 132, 131, 253, 51, 108, 243, 80, 194, 193, 254, 25,
+			150, 59, 200, 223, 177, 96, 9, 181, 129, 125, 49, 231, 255, 75,
+			11, 80, 32, 229, 169, 223, 108, 165, 252, 72, 84, 10, 242, 229,
+			107, 37, 192, 185, 55, 231, 119, 78, 237, 21, 27, 162, 5, 185,
+			60, 86, 139, 74, 115, 43, 25, 220, 48, 73, 67, 114, 173, 133,
+			46, 232, 23, 139, 227, 169, 96, 100, 128, 92, 53, 116, 216, 134,
+			134, 121, 12, 151, 151, 139, 50, 44, 73, 245, 15, 225, 215, 54,
+			110, 83, 0, 226, 147, 174, 23, 192, 140, 192, 146, 208, 232, 37,
+			127, 14, 209, 186, 203, 81, 133, 212, 238, 248, 5, 3, 63, 53,
+			195, 131, 0, 87, 206, 4, 72, 192, 82, 173, 88, 45, 100, 199,
+			47, 4, 213, 202, 202, 195, 229, 89, 24, 8, 178, 51, 148, 83,
+			139, 109, 217, 14, 52, 105, 43, 37, 83, 34, 217, 230, 83, 146,
+			137, 100, 247, 0, 37, 161, 253, 51, 89, 96, 218, 183, 109, 230,
+			165, 94, 96, 206, 43, 76, 236, 200, 147, 119, 34, 65, 30, 52,
+			246, 44, 64, 25, 144, 63, 60, 237, 114, 52, 216, 156, 58, 185,
+			37, 113, 108, 116, 238, 110, 245, 249, 63, 197, 177, 15, 108, 187,
+			223, 101, 233, 81, 255, 69, 70, 188, 171, 120, 229, 144, 216, 111,
+			18, 14, 25, 110, 193, 212, 110, 167, 142, 70, 31, 85, 136, 17,
+			15, 128, 91, 51, 141, 60, 177, 18, 44, 215, 42, 203, 101, 128,
+			81, 173, 197, 53, 216, 171, 41, 188, 59, 164, 2, 51, 209, 241,
+			140, 77, 182, 26, 213, 16, 42, 42, 36, 222, 163, 51, 192, 169,
+			188, 111, 88, 103, 216, 34, 227, 240, 17, 216, 75, 139, 12, 230,
+			57, 47, 177, 244, 160, 63, 216, 112, 114, 134, 186, 80, 82, 198,
+			235, 75, 21, 163, 58, 230, 194, 203, 155, 117, 6, 148, 182, 101,
+			191, 206, 176, 69, 70, 255, 0, 63, 44, 171, 179, 60, 231, 101,
+			150, 206, 248, 123, 145, 69, 84, 93, 169, 244, 199, 3, 208, 26,
+			81, 62, 19, 224, 166, 214, 168, 199, 114, 224, 173, 118, 157, 145,
+			18, 25, 91, 187, 117, 6, 120, 144, 247, 220, 166, 51, 192, 135,
+			124, 112, 136, 191, 141, 179, 215, 241, 156, 127, 194, 156, 19, 254,
+			155, 150, 2, 184, 221, 176, 198, 104, 88, 209, 184, 233, 28, 185,
+			161, 89, 44, 47, 92, 197, 79, 229, 249, 249, 56, 170, 6, 195,
+			162, 152, 11, 133, 165, 66, 53, 57, 219, 70, 134, 115, 193, 217,
+			90, 5, 44, 231, 162, 254, 25, 157, 182, 73, 90, 73, 145, 151,
+			231, 65, 92, 16, 182, 203, 40, 237, 43, 227, 32, 92, 40, 7,
+			253, 146, 68, 217, 220, 100, 25, 70, 8, 30, 193, 2, 63, 20,
+			146, 130, 135, 177, 113, 26, 157, 88, 33, 76, 6, 215, 1, 53,
+			113, 29, 108, 77, 154, 184, 78, 74, 36, 219, 186, 41, 201, 68,
+			178, 231, 40, 37, 109, 145, 60, 118, 156, 111, 134, 137, 107, 121,
+			169, 31, 51, 231, 45, 230, 202, 89, 103, 129, 3, 121, 235, 78,
+			154, 117, 16, 168, 251, 39, 159, 160, 89, 7, 123, 62, 33, 241,
+			30, 157, 193, 68, 134, 154, 117, 176, 243, 115, 254, 68, 207, 58,
+			24, 174, 63, 185, 181, 89, 7, 65, 184, 226, 229, 205, 58, 3,
+			74, 83, 179, 14, 54, 108, 206, 79, 244, 172, 131, 112, 219, 55,
+			110, 114, 214, 89, 48, 235, 222, 208, 179, 14, 162, 107, 157, 55,
+			244, 172, 131, 0, 91, 231, 13, 61, 235, 32, 198, 214, 121, 67,
+			204, 186, 111, 216, 178, 102, 219, 115, 126, 206, 210, 251, 253, 191,
+			177, 130, 251, 228, 126, 208, 232, 62, 152, 112, 97, 189, 121, 112,
+			171, 38, 3, 95, 221, 8, 65, 147, 33, 19, 132, 243, 213, 168,
+			178, 138, 73, 64, 11, 194, 113, 67, 95, 168, 99, 28, 25, 130,
+			44, 134, 142, 232, 156, 107, 101, 158, 172, 58, 14, 250, 129, 200,
+			191, 122, 173, 12, 139, 195, 0, 249, 155, 193, 161, 159, 242, 132,
+			21, 239, 213, 197, 148, 95, 44, 169, 193, 44, 94, 198, 95, 133,
+			158, 4, 224, 219, 107, 101, 243, 27, 136, 169, 31, 189, 39, 241,
+			192, 40, 83, 215, 144, 241, 106, 13, 169, 122, 70, 216, 59, 63,
+			103, 233, 86, 157, 193, 68, 70, 91, 160, 51, 160, 171, 246, 238,
+			227, 127, 224, 194, 138, 233, 122, 206, 159, 49, 231, 160, 255, 53,
+			23, 61, 54, 141, 213, 44, 161, 235, 235, 111, 27, 111, 217, 60,
+			81, 94, 118, 255, 183, 204, 18, 179, 86, 241, 248, 248, 133, 56,
+			8, 225, 50, 91, 94, 73, 215, 170, 229, 37, 121, 19, 9, 152,
+			200, 248, 98, 6, 164, 186, 90, 46, 228, 131, 16, 112, 197, 11,
+			165, 133, 34, 240, 193, 0, 255, 186, 190, 92, 85, 5, 204, 5,
+			181, 229, 74, 116, 85, 172, 172, 115, 81, 28, 211, 126, 27, 174,
+			116, 3, 131, 218, 20, 190, 96, 100, 216, 236, 254, 155, 85, 60,
+			52, 62, 126, 55, 20, 143, 235, 192, 160, 36, 197, 227, 166, 68,
+			82, 89, 140, 46, 19, 201, 238, 65, 74, 218, 34, 153, 205, 145,
+			11, 229, 255, 9, 0, 0, 255, 255, 232, 249, 246, 29, 92, 83,
+			2, 0},
+	)
+}
+
+// FileDescriptorSet returns a descriptor set for this proto package, which
+// includes all defined services, and all transitive dependencies.
+//
+// Will not return nil.
+//
+// Do NOT modify the returned descriptor.
+func FileDescriptorSet() *descriptorpb.FileDescriptorSet {
+	// We just need ONE of the service names to look up the FileDescriptorSet.
+	ret, err := discovery.GetDescriptorSet("weetbix.v1.Clusters")
+	if err != nil {
+		panic(err)
+	}
+	return ret
+}
diff --git a/analysis/proto/v1/predicate.pb.go b/analysis/proto/v1/predicate.pb.go
new file mode 100644
index 0000000..7a304c2
--- /dev/null
+++ b/analysis/proto/v1/predicate.pb.go
@@ -0,0 +1,353 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/predicate.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Represents a function Variant -> bool.
+type VariantPredicate struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Types that are assignable to Predicate:
+	//	*VariantPredicate_Equals
+	//	*VariantPredicate_Contains
+	//	*VariantPredicate_HashEquals
+	Predicate isVariantPredicate_Predicate `protobuf_oneof:"predicate"`
+}
+
+func (x *VariantPredicate) Reset() {
+	*x = VariantPredicate{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *VariantPredicate) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VariantPredicate) ProtoMessage() {}
+
+func (x *VariantPredicate) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VariantPredicate.ProtoReflect.Descriptor instead.
+func (*VariantPredicate) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescGZIP(), []int{0}
+}
+
+func (m *VariantPredicate) GetPredicate() isVariantPredicate_Predicate {
+	if m != nil {
+		return m.Predicate
+	}
+	return nil
+}
+
+func (x *VariantPredicate) GetEquals() *Variant {
+	if x, ok := x.GetPredicate().(*VariantPredicate_Equals); ok {
+		return x.Equals
+	}
+	return nil
+}
+
+func (x *VariantPredicate) GetContains() *Variant {
+	if x, ok := x.GetPredicate().(*VariantPredicate_Contains); ok {
+		return x.Contains
+	}
+	return nil
+}
+
+func (x *VariantPredicate) GetHashEquals() string {
+	if x, ok := x.GetPredicate().(*VariantPredicate_HashEquals); ok {
+		return x.HashEquals
+	}
+	return ""
+}
+
+type isVariantPredicate_Predicate interface {
+	isVariantPredicate_Predicate()
+}
+
+type VariantPredicate_Equals struct {
+	// A variant must be equal this definition exactly.
+	Equals *Variant `protobuf:"bytes,1,opt,name=equals,proto3,oneof"`
+}
+
+type VariantPredicate_Contains struct {
+	// A variant's key-value pairs must contain those in this one.
+	Contains *Variant `protobuf:"bytes,2,opt,name=contains,proto3,oneof"`
+}
+
+type VariantPredicate_HashEquals struct {
+	// A variant's hash must equal this value exactly.
+	HashEquals string `protobuf:"bytes,3,opt,name=hash_equals,json=hashEquals,proto3,oneof"`
+}
+
+func (*VariantPredicate_Equals) isVariantPredicate_Predicate() {}
+
+func (*VariantPredicate_Contains) isVariantPredicate_Predicate() {}
+
+func (*VariantPredicate_HashEquals) isVariantPredicate_Predicate() {}
+
+// Represents a function TestVerdict -> bool.
+type TestVerdictPredicate struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Optional. The project-scoped realm to query the history from.
+	// This is the realm without the "<project>:" prefix.
+	//
+	// When specified, only the test history entries found in the matching realm
+	// will be returned.
+	SubRealm string `protobuf:"bytes,1,opt,name=sub_realm,json=subRealm,proto3" json:"sub_realm,omitempty"`
+	// Optional. The subset of test variants to request history for.
+	VariantPredicate *VariantPredicate `protobuf:"bytes,2,opt,name=variant_predicate,json=variantPredicate,proto3" json:"variant_predicate,omitempty"`
+	// Optional. Whether test verdicts generated by code with unsubmitted changes
+	// (e.g. Gerrit changes) should be included in the response.
+	//
+	// If no filter is specified, all verdicts are returned (regardless of
+	// submitted status).
+	SubmittedFilter SubmittedFilter `protobuf:"varint,3,opt,name=submitted_filter,json=submittedFilter,proto3,enum=weetbix.v1.SubmittedFilter" json:"submitted_filter,omitempty"`
+	// Optional. Specify a range of timestamps to query the test history from.
+	//
+	// Test history older than the configured TTL (90 days) will not be returned.
+	// When omitted, return all available test history.
+	PartitionTimeRange *TimeRange `protobuf:"bytes,4,opt,name=partition_time_range,json=partitionTimeRange,proto3" json:"partition_time_range,omitempty"`
+}
+
+func (x *TestVerdictPredicate) Reset() {
+	*x = TestVerdictPredicate{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVerdictPredicate) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVerdictPredicate) ProtoMessage() {}
+
+func (x *TestVerdictPredicate) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVerdictPredicate.ProtoReflect.Descriptor instead.
+func (*TestVerdictPredicate) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *TestVerdictPredicate) GetSubRealm() string {
+	if x != nil {
+		return x.SubRealm
+	}
+	return ""
+}
+
+func (x *TestVerdictPredicate) GetVariantPredicate() *VariantPredicate {
+	if x != nil {
+		return x.VariantPredicate
+	}
+	return nil
+}
+
+func (x *TestVerdictPredicate) GetSubmittedFilter() SubmittedFilter {
+	if x != nil {
+		return x.SubmittedFilter
+	}
+	return SubmittedFilter_SUBMITTED_FILTER_UNSPECIFIED
+}
+
+func (x *TestVerdictPredicate) GetPartitionTimeRange() *TimeRange {
+	if x != nil {
+		return x.PartitionTimeRange
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_proto_v1_predicate_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDesc = []byte{
+	0x0a, 0x30, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x1a, 0x2d,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31,
+	0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa4, 0x01,
+	0x0a, 0x10, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61,
+	0x74, 0x65, 0x12, 0x2d, 0x0a, 0x06, 0x65, 0x71, 0x75, 0x61, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x06, 0x65, 0x71, 0x75, 0x61, 0x6c,
+	0x73, 0x12, 0x31, 0x0a, 0x08, 0x63, 0x6f, 0x6e, 0x74, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x08, 0x63, 0x6f, 0x6e, 0x74,
+	0x61, 0x69, 0x6e, 0x73, 0x12, 0x21, 0x0a, 0x0b, 0x68, 0x61, 0x73, 0x68, 0x5f, 0x65, 0x71, 0x75,
+	0x61, 0x6c, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x61, 0x73,
+	0x68, 0x45, 0x71, 0x75, 0x61, 0x6c, 0x73, 0x42, 0x0b, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x64, 0x69,
+	0x63, 0x61, 0x74, 0x65, 0x22, 0x8f, 0x02, 0x0a, 0x14, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x72,
+	0x64, 0x69, 0x63, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a,
+	0x09, 0x73, 0x75, 0x62, 0x5f, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x08, 0x73, 0x75, 0x62, 0x52, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x49, 0x0a, 0x11, 0x76, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63,
+	0x61, 0x74, 0x65, 0x52, 0x10, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x64,
+	0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x46, 0x0a, 0x10, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x74, 0x74,
+	0x65, 0x64, 0x5f, 0x66, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32,
+	0x1b, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62,
+	0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x52, 0x0f, 0x73, 0x75,
+	0x62, 0x6d, 0x69, 0x74, 0x74, 0x65, 0x64, 0x46, 0x69, 0x6c, 0x74, 0x65, 0x72, 0x12, 0x47, 0x0a,
+	0x14, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x5f,
+	0x72, 0x61, 0x6e, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x52, 0x61, 0x6e,
+	0x67, 0x65, 0x52, 0x12, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d,
+	0x65, 0x52, 0x61, 0x6e, 0x67, 0x65, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f,
+	0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_infra_appengine_weetbix_proto_v1_predicate_proto_goTypes = []interface{}{
+	(*VariantPredicate)(nil),     // 0: weetbix.v1.VariantPredicate
+	(*TestVerdictPredicate)(nil), // 1: weetbix.v1.TestVerdictPredicate
+	(*Variant)(nil),              // 2: weetbix.v1.Variant
+	(SubmittedFilter)(0),         // 3: weetbix.v1.SubmittedFilter
+	(*TimeRange)(nil),            // 4: weetbix.v1.TimeRange
+}
+var file_infra_appengine_weetbix_proto_v1_predicate_proto_depIdxs = []int32{
+	2, // 0: weetbix.v1.VariantPredicate.equals:type_name -> weetbix.v1.Variant
+	2, // 1: weetbix.v1.VariantPredicate.contains:type_name -> weetbix.v1.Variant
+	0, // 2: weetbix.v1.TestVerdictPredicate.variant_predicate:type_name -> weetbix.v1.VariantPredicate
+	3, // 3: weetbix.v1.TestVerdictPredicate.submitted_filter:type_name -> weetbix.v1.SubmittedFilter
+	4, // 4: weetbix.v1.TestVerdictPredicate.partition_time_range:type_name -> weetbix.v1.TimeRange
+	5, // [5:5] is the sub-list for method output_type
+	5, // [5:5] is the sub-list for method input_type
+	5, // [5:5] is the sub-list for extension type_name
+	5, // [5:5] is the sub-list for extension extendee
+	0, // [0:5] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_predicate_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_predicate_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_predicate_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_v1_common_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*VariantPredicate); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVerdictPredicate); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes[0].OneofWrappers = []interface{}{
+		(*VariantPredicate_Equals)(nil),
+		(*VariantPredicate_Contains)(nil),
+		(*VariantPredicate_HashEquals)(nil),
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_predicate_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_predicate_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_predicate_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_predicate_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_depIdxs = nil
+}
diff --git a/analysis/proto/v1/predicate.proto b/analysis/proto/v1/predicate.proto
new file mode 100644
index 0000000..aa35b2a
--- /dev/null
+++ b/analysis/proto/v1/predicate.proto
@@ -0,0 +1,61 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// Represents a function Variant -> bool.
+message VariantPredicate {
+  oneof predicate {
+    // A variant must be equal this definition exactly.
+    Variant equals = 1;
+
+    // A variant's key-value pairs must contain those in this one.
+    Variant contains = 2;
+
+    // A variant's hash must equal this value exactly.
+    string hash_equals = 3;
+  }
+}
+
+// Represents a function TestVerdict -> bool.
+message TestVerdictPredicate {
+  // Optional. The project-scoped realm to query the history from.
+  // This is the realm without the "<project>:" prefix.
+  //
+  // When specified, only the test history entries found in the matching realm
+  // will be returned.
+  string sub_realm = 1;
+
+  // Optional. The subset of test variants to request history for.
+  VariantPredicate variant_predicate = 2;
+
+  // Optional. Whether test verdicts generated by code with unsubmitted changes
+  // (e.g. Gerrit changes) should be included in the response.
+  //
+  // If no filter is specified, all verdicts are returned (regardless of
+  // submitted status).
+  weetbix.v1.SubmittedFilter submitted_filter = 3;
+
+  // Optional. Specify a range of timestamps to query the test history from.
+  //
+  // Test history older than the configured TTL (90 days) will not be returned.
+  // When omitted, return all available test history.
+  TimeRange partition_time_range = 4;
+}
diff --git a/analysis/proto/v1/project.pb.go b/analysis/proto/v1/project.pb.go
new file mode 100644
index 0000000..9dc4ed5
--- /dev/null
+++ b/analysis/proto/v1/project.pb.go
@@ -0,0 +1,185 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/project.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type Project struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The resource name of the project which can be used to access the project.
+	// Format: projects/{project}.
+	// See also https://google.aip.dev/122.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The display name to be used in the project selection page of Weetbix.
+	// If not provided, the Title case of the project's Luci project ID will be used.
+	DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"`
+	// The project id in luci, e.g. "chromium".
+	Project string `protobuf:"bytes,3,opt,name=project,proto3" json:"project,omitempty"`
+}
+
+func (x *Project) Reset() {
+	*x = Project{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_project_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Project) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Project) ProtoMessage() {}
+
+func (x *Project) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_project_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Project.ProtoReflect.Descriptor instead.
+func (*Project) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_project_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Project) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Project) GetDisplayName() string {
+	if x != nil {
+		return x.DisplayName
+	}
+	return ""
+}
+
+func (x *Project) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+var File_infra_appengine_weetbix_proto_v1_project_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_project_proto_rawDesc = []byte{
+	0x0a, 0x2e, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x22, 0x5a, 0x0a, 0x07,
+	0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18,
+	0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x64,
+	0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x18,
+	0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72,
+	0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_project_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_project_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_project_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_project_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_project_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_project_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_project_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_project_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_project_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_proto_v1_project_proto_goTypes = []interface{}{
+	(*Project)(nil), // 0: weetbix.v1.Project
+}
+var file_infra_appengine_weetbix_proto_v1_project_proto_depIdxs = []int32{
+	0, // [0:0] is the sub-list for method output_type
+	0, // [0:0] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_project_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_project_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_project_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_project_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Project); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_project_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_project_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_project_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_project_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_project_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_project_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_project_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_project_proto_depIdxs = nil
+}
diff --git a/analysis/proto/v1/project.proto b/analysis/proto/v1/project.proto
new file mode 100644
index 0000000..965067c
--- /dev/null
+++ b/analysis/proto/v1/project.proto
@@ -0,0 +1,34 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+message Project {
+
+    // The resource name of the project which can be used to access the project.
+    // Format: projects/{project}.
+    // See also https://google.aip.dev/122.
+    string name = 1;
+
+    // The display name to be used in the project selection page of Weetbix.
+    // If not provided, the Title case of the project's Luci project ID will be used.
+    string display_name = 2;
+
+    // The project id in luci, e.g. "chromium".
+    string project = 3;
+}
\ No newline at end of file
diff --git a/analysis/proto/v1/projects.pb.go b/analysis/proto/v1/projects.pb.go
new file mode 100644
index 0000000..6cd864d
--- /dev/null
+++ b/analysis/proto/v1/projects.pb.go
@@ -0,0 +1,622 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/projects.proto
+
+package weetbixpb
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// A request object with data to fetch the list of projects configured
+// by Weetbix.
+type ListProjectsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+}
+
+func (x *ListProjectsRequest) Reset() {
+	*x = ListProjectsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListProjectsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListProjectsRequest) ProtoMessage() {}
+
+func (x *ListProjectsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListProjectsRequest.ProtoReflect.Descriptor instead.
+func (*ListProjectsRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescGZIP(), []int{0}
+}
+
+// A response containing the list of projects which are are using Weetbix.
+type ListProjectsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The list of projects using Weetbix.
+	Projects []*Project `protobuf:"bytes,1,rep,name=projects,proto3" json:"projects,omitempty"`
+}
+
+func (x *ListProjectsResponse) Reset() {
+	*x = ListProjectsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListProjectsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListProjectsResponse) ProtoMessage() {}
+
+func (x *ListProjectsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListProjectsResponse.ProtoReflect.Descriptor instead.
+func (*ListProjectsResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ListProjectsResponse) GetProjects() []*Project {
+	if x != nil {
+		return x.Projects
+	}
+	return nil
+}
+
+type GetProjectConfigRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the project configuration to retrieve.
+	// Format: projects/{project}/config.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetProjectConfigRequest) Reset() {
+	*x = GetProjectConfigRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetProjectConfigRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetProjectConfigRequest) ProtoMessage() {}
+
+func (x *GetProjectConfigRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetProjectConfigRequest.ProtoReflect.Descriptor instead.
+func (*GetProjectConfigRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *GetProjectConfigRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+type ProjectConfig struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Resource name of the project configuration.
+	// Format: projects/{project}/config.
+	// See also https://google.aip.dev/122.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// Details about the monorail project used for this LUCI project.
+	Monorail *ProjectConfig_Monorail `protobuf:"bytes,2,opt,name=monorail,proto3" json:"monorail,omitempty"`
+}
+
+func (x *ProjectConfig) Reset() {
+	*x = ProjectConfig{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectConfig) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectConfig) ProtoMessage() {}
+
+func (x *ProjectConfig) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectConfig.ProtoReflect.Descriptor instead.
+func (*ProjectConfig) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ProjectConfig) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *ProjectConfig) GetMonorail() *ProjectConfig_Monorail {
+	if x != nil {
+		return x.Monorail
+	}
+	return nil
+}
+
+type ProjectConfig_Monorail struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The monorail project used for this LUCI project.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// The shortlink format used for this bug tracker.
+	// For example, "crbug.com".
+	DisplayPrefix string `protobuf:"bytes,2,opt,name=display_prefix,json=displayPrefix,proto3" json:"display_prefix,omitempty"`
+}
+
+func (x *ProjectConfig_Monorail) Reset() {
+	*x = ProjectConfig_Monorail{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ProjectConfig_Monorail) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ProjectConfig_Monorail) ProtoMessage() {}
+
+func (x *ProjectConfig_Monorail) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ProjectConfig_Monorail.ProtoReflect.Descriptor instead.
+func (*ProjectConfig_Monorail) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescGZIP(), []int{3, 0}
+}
+
+func (x *ProjectConfig_Monorail) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *ProjectConfig_Monorail) GetDisplayPrefix() string {
+	if x != nil {
+		return x.DisplayPrefix
+	}
+	return ""
+}
+
+var File_infra_appengine_weetbix_proto_v1_projects_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_projects_proto_rawDesc = []byte{
+	0x0a, 0x2f, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x1a, 0x2e, 0x69,
+	0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f,
+	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x15, 0x0a,
+	0x13, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x22, 0x47, 0x0a, 0x14, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2f, 0x0a, 0x08,
+	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x22, 0x2d, 0x0a,
+	0x17, 0x47, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69,
+	0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0xb0, 0x01, 0x0a,
+	0x0d, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x12,
+	0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61,
+	0x6d, 0x65, 0x12, 0x3e, 0x0a, 0x08, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e,
+	0x4d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x52, 0x08, 0x6d, 0x6f, 0x6e, 0x6f, 0x72, 0x61,
+	0x69, 0x6c, 0x1a, 0x4b, 0x0a, 0x08, 0x4d, 0x6f, 0x6e, 0x6f, 0x72, 0x61, 0x69, 0x6c, 0x12, 0x18,
+	0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x64, 0x69, 0x73, 0x70,
+	0x6c, 0x61, 0x79, 0x5f, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x0d, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x32,
+	0xa6, 0x01, 0x0a, 0x08, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x12, 0x4d, 0x0a, 0x09,
+	0x47, 0x65, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x23, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63,
+	0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x72, 0x6f, 0x6a,
+	0x65, 0x63, 0x74, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x00, 0x12, 0x4b, 0x0a, 0x04, 0x4c,
+	0x69, 0x73, 0x74, 0x12, 0x1f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x50, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x73, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72,
+	0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_projects_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_projects_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes = make([]protoimpl.MessageInfo, 5)
+var file_infra_appengine_weetbix_proto_v1_projects_proto_goTypes = []interface{}{
+	(*ListProjectsRequest)(nil),     // 0: weetbix.v1.ListProjectsRequest
+	(*ListProjectsResponse)(nil),    // 1: weetbix.v1.ListProjectsResponse
+	(*GetProjectConfigRequest)(nil), // 2: weetbix.v1.GetProjectConfigRequest
+	(*ProjectConfig)(nil),           // 3: weetbix.v1.ProjectConfig
+	(*ProjectConfig_Monorail)(nil),  // 4: weetbix.v1.ProjectConfig.Monorail
+	(*Project)(nil),                 // 5: weetbix.v1.Project
+}
+var file_infra_appengine_weetbix_proto_v1_projects_proto_depIdxs = []int32{
+	5, // 0: weetbix.v1.ListProjectsResponse.projects:type_name -> weetbix.v1.Project
+	4, // 1: weetbix.v1.ProjectConfig.monorail:type_name -> weetbix.v1.ProjectConfig.Monorail
+	2, // 2: weetbix.v1.Projects.GetConfig:input_type -> weetbix.v1.GetProjectConfigRequest
+	0, // 3: weetbix.v1.Projects.List:input_type -> weetbix.v1.ListProjectsRequest
+	3, // 4: weetbix.v1.Projects.GetConfig:output_type -> weetbix.v1.ProjectConfig
+	1, // 5: weetbix.v1.Projects.List:output_type -> weetbix.v1.ListProjectsResponse
+	4, // [4:6] is the sub-list for method output_type
+	2, // [2:4] is the sub-list for method input_type
+	2, // [2:2] is the sub-list for extension type_name
+	2, // [2:2] is the sub-list for extension extendee
+	0, // [0:2] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_projects_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_projects_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_projects_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_v1_project_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListProjectsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListProjectsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetProjectConfigRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectConfig); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ProjectConfig_Monorail); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_projects_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   5,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_projects_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_projects_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_projects_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_projects_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_projects_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_projects_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_projects_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// ProjectsClient is the client API for Projects service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type ProjectsClient interface {
+	// Gets Weetbix configuration for a LUCI Project.
+	//
+	// RPC desigend to comply with https://google.aip.dev/131.
+	GetConfig(ctx context.Context, in *GetProjectConfigRequest, opts ...grpc.CallOption) (*ProjectConfig, error)
+	// Lists LUCI Projects visible to the user.
+	//
+	// RPC compliant with https://google.aip.dev/132.
+	// This RPC is incomplete. Future breaking changes are
+	// expressly flagged.
+	List(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ListProjectsResponse, error)
+}
+type projectsPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewProjectsPRPCClient(client *prpc.Client) ProjectsClient {
+	return &projectsPRPCClient{client}
+}
+
+func (c *projectsPRPCClient) GetConfig(ctx context.Context, in *GetProjectConfigRequest, opts ...grpc.CallOption) (*ProjectConfig, error) {
+	out := new(ProjectConfig)
+	err := c.client.Call(ctx, "weetbix.v1.Projects", "GetConfig", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsPRPCClient) List(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ListProjectsResponse, error) {
+	out := new(ListProjectsResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Projects", "List", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type projectsClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewProjectsClient(cc grpc.ClientConnInterface) ProjectsClient {
+	return &projectsClient{cc}
+}
+
+func (c *projectsClient) GetConfig(ctx context.Context, in *GetProjectConfigRequest, opts ...grpc.CallOption) (*ProjectConfig, error) {
+	out := new(ProjectConfig)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Projects/GetConfig", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *projectsClient) List(ctx context.Context, in *ListProjectsRequest, opts ...grpc.CallOption) (*ListProjectsResponse, error) {
+	out := new(ListProjectsResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Projects/List", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// ProjectsServer is the server API for Projects service.
+type ProjectsServer interface {
+	// Gets Weetbix configuration for a LUCI Project.
+	//
+	// RPC desigend to comply with https://google.aip.dev/131.
+	GetConfig(context.Context, *GetProjectConfigRequest) (*ProjectConfig, error)
+	// Lists LUCI Projects visible to the user.
+	//
+	// RPC compliant with https://google.aip.dev/132.
+	// This RPC is incomplete. Future breaking changes are
+	// expressly flagged.
+	List(context.Context, *ListProjectsRequest) (*ListProjectsResponse, error)
+}
+
+// UnimplementedProjectsServer can be embedded to have forward compatible implementations.
+type UnimplementedProjectsServer struct {
+}
+
+func (*UnimplementedProjectsServer) GetConfig(context.Context, *GetProjectConfigRequest) (*ProjectConfig, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method GetConfig not implemented")
+}
+func (*UnimplementedProjectsServer) List(context.Context, *ListProjectsRequest) (*ListProjectsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method List not implemented")
+}
+
+func RegisterProjectsServer(s prpc.Registrar, srv ProjectsServer) {
+	s.RegisterService(&_Projects_serviceDesc, srv)
+}
+
+func _Projects_GetConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetProjectConfigRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).GetConfig(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Projects/GetConfig",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).GetConfig(ctx, req.(*GetProjectConfigRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Projects_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListProjectsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(ProjectsServer).List(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Projects/List",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(ProjectsServer).List(ctx, req.(*ListProjectsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Projects_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.v1.Projects",
+	HandlerType: (*ProjectsServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "GetConfig",
+			Handler:    _Projects_GetConfig_Handler,
+		},
+		{
+			MethodName: "List",
+			Handler:    _Projects_List_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/proto/v1/projects.proto",
+}
diff --git a/analysis/proto/v1/projects.proto b/analysis/proto/v1/projects.proto
new file mode 100644
index 0000000..0fbfa2c
--- /dev/null
+++ b/analysis/proto/v1/projects.proto
@@ -0,0 +1,72 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+import "go.chromium.org/luci/analysis/proto/v1/project.proto";
+
+
+// Provides methods to access the projects which are using Weetbix.
+service Projects {
+  // Gets Weetbix configuration for a LUCI Project.
+  //
+  // RPC desigend to comply with https://google.aip.dev/131.
+  rpc GetConfig(GetProjectConfigRequest) returns (ProjectConfig) {};
+
+  // Lists LUCI Projects visible to the user.
+  //
+  // RPC compliant with https://google.aip.dev/132.
+  // This RPC is incomplete. Future breaking changes are
+  // expressly flagged.
+  rpc List(ListProjectsRequest) returns (ListProjectsResponse) {};
+}
+
+// A request object with data to fetch the list of projects configured
+// by Weetbix.
+message ListProjectsRequest {}
+
+// A response containing the list of projects which are are using Weetbix.
+message ListProjectsResponse {
+  // The list of projects using Weetbix.
+  repeated Project projects = 1;
+}
+
+message GetProjectConfigRequest {
+  // The name of the project configuration to retrieve.
+  // Format: projects/{project}/config.
+  string name = 1;
+}
+
+message ProjectConfig {
+  message Monorail {
+    // The monorail project used for this LUCI project.
+    string project = 1;
+
+    // The shortlink format used for this bug tracker.
+    // For example, "crbug.com".
+    string display_prefix = 2;
+  }
+
+  // Resource name of the project configuration.
+  // Format: projects/{project}/config.
+  // See also https://google.aip.dev/122.
+  string name = 1;
+
+  // Details about the monorail project used for this LUCI project.
+  Monorail monorail = 2;
+}
diff --git a/analysis/proto/v1/projectsserver_dec.go b/analysis/proto/v1/projectsserver_dec.go
new file mode 100644
index 0000000..d2d3619
--- /dev/null
+++ b/analysis/proto/v1/projectsserver_dec.go
@@ -0,0 +1,58 @@
+// Code generated by svcdec; DO NOT EDIT.
+
+package weetbixpb
+
+import (
+	"context"
+
+	proto "github.com/golang/protobuf/proto"
+)
+
+type DecoratedProjects struct {
+	// Service is the service to decorate.
+	Service ProjectsServer
+	// Prelude is called for each method before forwarding the call to Service.
+	// If Prelude returns an error, then the call is skipped and the error is
+	// processed via the Postlude (if one is defined), or it is returned directly.
+	Prelude func(ctx context.Context, methodName string, req proto.Message) (context.Context, error)
+	// Postlude is called for each method after Service has processed the call, or
+	// after the Prelude has returned an error. This takes the the Service's
+	// response proto (which may be nil) and/or any error. The decorated
+	// service will return the response (possibly mutated) and error that Postlude
+	// returns.
+	Postlude func(ctx context.Context, methodName string, rsp proto.Message, err error) error
+}
+
+func (s *DecoratedProjects) GetConfig(ctx context.Context, req *GetProjectConfigRequest) (rsp *ProjectConfig, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "GetConfig", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.GetConfig(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "GetConfig", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedProjects) List(ctx context.Context, req *ListProjectsRequest) (rsp *ListProjectsResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "List", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.List(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "List", rsp, err)
+	}
+	return
+}
diff --git a/analysis/proto/v1/rules.pb.go b/analysis/proto/v1/rules.pb.go
new file mode 100644
index 0000000..ba0eb9a
--- /dev/null
+++ b/analysis/proto/v1/rules.pb.go
@@ -0,0 +1,1207 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/rules.proto
+
+package weetbixpb
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	fieldmaskpb "google.golang.org/protobuf/types/known/fieldmaskpb"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// A rule associating failures with a bug.
+// Next ID: 15.
+type Rule struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Can be used to refer to this rule, e.g. in RulesService.Get RPC.
+	// Format: projects/{project}/rules/{rule_id}.
+	// See also https://google.aip.dev/122.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+	// The LUCI Project for which this rule is defined.
+	Project string `protobuf:"bytes,2,opt,name=project,proto3" json:"project,omitempty"`
+	// The unique identifier for the failure association rule,
+	// as 32 lowercase hexadecimal characters.
+	RuleId string `protobuf:"bytes,3,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"`
+	// The rule predicate, defining which failures are being associated.
+	RuleDefinition string `protobuf:"bytes,4,opt,name=rule_definition,json=ruleDefinition,proto3" json:"rule_definition,omitempty"`
+	// The bug that the failures are associated with.
+	Bug *AssociatedBug `protobuf:"bytes,5,opt,name=bug,proto3" json:"bug,omitempty"`
+	// Whether the bug should be updated by Weetbix, and whether failures
+	// should still be matched against the rule.
+	IsActive bool `protobuf:"varint,6,opt,name=is_active,json=isActive,proto3" json:"is_active,omitempty"`
+	// Whether Weetbix should manage the priority and verified status
+	// of the associated bug based on the impact established via this rule.
+	IsManagingBug bool `protobuf:"varint,14,opt,name=is_managing_bug,json=isManagingBug,proto3" json:"is_managing_bug,omitempty"`
+	// The suggested cluster this rule was created from (if any).
+	// Until re-clustering is complete and has reduced the residual impact
+	// of the source cluster, this cluster ID tells bug filing to ignore
+	// the source cluster when determining whether new bugs need to be filed.
+	SourceCluster *ClusterId `protobuf:"bytes,7,opt,name=source_cluster,json=sourceCluster,proto3" json:"source_cluster,omitempty"`
+	// The time the rule was created.
+	CreateTime *timestamppb.Timestamp `protobuf:"bytes,8,opt,name=create_time,json=createTime,proto3" json:"create_time,omitempty"`
+	// The user which created the rule.
+	CreateUser string `protobuf:"bytes,9,opt,name=create_user,json=createUser,proto3" json:"create_user,omitempty"`
+	// The time the rule was last updated.
+	LastUpdateTime *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=last_update_time,json=lastUpdateTime,proto3" json:"last_update_time,omitempty"`
+	// The user which last updated the rule.
+	LastUpdateUser string `protobuf:"bytes,11,opt,name=last_update_user,json=lastUpdateUser,proto3" json:"last_update_user,omitempty"`
+	// The time the rule was last updated in a way that caused the
+	// matched failures to change, i.e. because of a change to rule_definition
+	// or is_active. (By contrast, updating the associated bug does NOT change
+	// the matched failures, so does NOT update this field.)
+	// Output only.
+	PredicateLastUpdateTime *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=predicate_last_update_time,json=predicateLastUpdateTime,proto3" json:"predicate_last_update_time,omitempty"`
+	// This checksum is computed by the server based on the value of other
+	// fields, and may be sent on update requests to ensure the client
+	// has an up-to-date value before proceeding.
+	// See also https://google.aip.dev/154.
+	Etag string `protobuf:"bytes,12,opt,name=etag,proto3" json:"etag,omitempty"`
+}
+
+func (x *Rule) Reset() {
+	*x = Rule{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *Rule) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Rule) ProtoMessage() {}
+
+func (x *Rule) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use Rule.ProtoReflect.Descriptor instead.
+func (*Rule) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Rule) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+func (x *Rule) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *Rule) GetRuleId() string {
+	if x != nil {
+		return x.RuleId
+	}
+	return ""
+}
+
+func (x *Rule) GetRuleDefinition() string {
+	if x != nil {
+		return x.RuleDefinition
+	}
+	return ""
+}
+
+func (x *Rule) GetBug() *AssociatedBug {
+	if x != nil {
+		return x.Bug
+	}
+	return nil
+}
+
+func (x *Rule) GetIsActive() bool {
+	if x != nil {
+		return x.IsActive
+	}
+	return false
+}
+
+func (x *Rule) GetIsManagingBug() bool {
+	if x != nil {
+		return x.IsManagingBug
+	}
+	return false
+}
+
+func (x *Rule) GetSourceCluster() *ClusterId {
+	if x != nil {
+		return x.SourceCluster
+	}
+	return nil
+}
+
+func (x *Rule) GetCreateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.CreateTime
+	}
+	return nil
+}
+
+func (x *Rule) GetCreateUser() string {
+	if x != nil {
+		return x.CreateUser
+	}
+	return ""
+}
+
+func (x *Rule) GetLastUpdateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.LastUpdateTime
+	}
+	return nil
+}
+
+func (x *Rule) GetLastUpdateUser() string {
+	if x != nil {
+		return x.LastUpdateUser
+	}
+	return ""
+}
+
+func (x *Rule) GetPredicateLastUpdateTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PredicateLastUpdateTime
+	}
+	return nil
+}
+
+func (x *Rule) GetEtag() string {
+	if x != nil {
+		return x.Etag
+	}
+	return ""
+}
+
+type GetRuleRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The name of the rule to retrieve.
+	// Format: projects/{project}/rules/{rule_id}.
+	Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
+}
+
+func (x *GetRuleRequest) Reset() {
+	*x = GetRuleRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *GetRuleRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*GetRuleRequest) ProtoMessage() {}
+
+func (x *GetRuleRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use GetRuleRequest.ProtoReflect.Descriptor instead.
+func (*GetRuleRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *GetRuleRequest) GetName() string {
+	if x != nil {
+		return x.Name
+	}
+	return ""
+}
+
+type ListRulesRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The parent, which owns this collection of rules.
+	// Format: projects/{project}.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+}
+
+func (x *ListRulesRequest) Reset() {
+	*x = ListRulesRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListRulesRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListRulesRequest) ProtoMessage() {}
+
+func (x *ListRulesRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListRulesRequest.ProtoReflect.Descriptor instead.
+func (*ListRulesRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *ListRulesRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+type ListRulesResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The rules.
+	Rules []*Rule `protobuf:"bytes,1,rep,name=rules,proto3" json:"rules,omitempty"`
+}
+
+func (x *ListRulesResponse) Reset() {
+	*x = ListRulesResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *ListRulesResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ListRulesResponse) ProtoMessage() {}
+
+func (x *ListRulesResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ListRulesResponse.ProtoReflect.Descriptor instead.
+func (*ListRulesResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ListRulesResponse) GetRules() []*Rule {
+	if x != nil {
+		return x.Rules
+	}
+	return nil
+}
+
+type CreateRuleRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The parent resource where the rule will be created.
+	// Format: projects/{project}.
+	Parent string `protobuf:"bytes,1,opt,name=parent,proto3" json:"parent,omitempty"`
+	// The rule to create.
+	Rule *Rule `protobuf:"bytes,2,opt,name=rule,proto3" json:"rule,omitempty"`
+}
+
+func (x *CreateRuleRequest) Reset() {
+	*x = CreateRuleRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *CreateRuleRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CreateRuleRequest) ProtoMessage() {}
+
+func (x *CreateRuleRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use CreateRuleRequest.ProtoReflect.Descriptor instead.
+func (*CreateRuleRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *CreateRuleRequest) GetParent() string {
+	if x != nil {
+		return x.Parent
+	}
+	return ""
+}
+
+func (x *CreateRuleRequest) GetRule() *Rule {
+	if x != nil {
+		return x.Rule
+	}
+	return nil
+}
+
+type UpdateRuleRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The rule to update.
+	//
+	// The rule's `name` field is used to identify the book to update.
+	// Format: projects/{project}/rules/{rule_id}.
+	Rule *Rule `protobuf:"bytes,1,opt,name=rule,proto3" json:"rule,omitempty"`
+	// The list of fields to update.
+	UpdateMask *fieldmaskpb.FieldMask `protobuf:"bytes,2,opt,name=update_mask,json=updateMask,proto3" json:"update_mask,omitempty"`
+	// The current etag of the rule.
+	// If an etag is provided and does not match the current etag of the rule,
+	// update will be blocked and an ABORTED error will be returned.
+	Etag string `protobuf:"bytes,3,opt,name=etag,proto3" json:"etag,omitempty"`
+}
+
+func (x *UpdateRuleRequest) Reset() {
+	*x = UpdateRuleRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *UpdateRuleRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*UpdateRuleRequest) ProtoMessage() {}
+
+func (x *UpdateRuleRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use UpdateRuleRequest.ProtoReflect.Descriptor instead.
+func (*UpdateRuleRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *UpdateRuleRequest) GetRule() *Rule {
+	if x != nil {
+		return x.Rule
+	}
+	return nil
+}
+
+func (x *UpdateRuleRequest) GetUpdateMask() *fieldmaskpb.FieldMask {
+	if x != nil {
+		return x.UpdateMask
+	}
+	return nil
+}
+
+func (x *UpdateRuleRequest) GetEtag() string {
+	if x != nil {
+		return x.Etag
+	}
+	return ""
+}
+
+type LookupBugRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// System is the bug tracking system of the bug. This is either
+	// "monorail" or "buganizer".
+	System string `protobuf:"bytes,1,opt,name=system,proto3" json:"system,omitempty"`
+	// Id is the bug tracking system-specific identity of the bug.
+	// For monorail, the scheme is {project}/{numeric_id}, for
+	// buganizer the scheme is {numeric_id}.
+	Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"`
+}
+
+func (x *LookupBugRequest) Reset() {
+	*x = LookupBugRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *LookupBugRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*LookupBugRequest) ProtoMessage() {}
+
+func (x *LookupBugRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use LookupBugRequest.ProtoReflect.Descriptor instead.
+func (*LookupBugRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *LookupBugRequest) GetSystem() string {
+	if x != nil {
+		return x.System
+	}
+	return ""
+}
+
+func (x *LookupBugRequest) GetId() string {
+	if x != nil {
+		return x.Id
+	}
+	return ""
+}
+
+type LookupBugResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The rules corresponding to the requested bug.
+	// Format: projects/{project}/rules/{rule_id}.
+	Rules []string `protobuf:"bytes,2,rep,name=rules,proto3" json:"rules,omitempty"`
+}
+
+func (x *LookupBugResponse) Reset() {
+	*x = LookupBugResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *LookupBugResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*LookupBugResponse) ProtoMessage() {}
+
+func (x *LookupBugResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use LookupBugResponse.ProtoReflect.Descriptor instead.
+func (*LookupBugResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *LookupBugResponse) GetRules() []string {
+	if x != nil {
+		return x.Rules
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_proto_v1_rules_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_rules_proto_rawDesc = []byte{
+	0x0a, 0x2c, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67,
+	0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68,
+	0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x20, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x66, 0x69, 0x65,
+	0x6c, 0x64, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x2d,
+	0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31,
+	0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x8e, 0x05,
+	0x0a, 0x04, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x07, 0x70, 0x72,
+	0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03,
+	0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x07, 0x72, 0x75, 0x6c,
+	0x65, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52,
+	0x06, 0x72, 0x75, 0x6c, 0x65, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x0f, 0x72, 0x75, 0x6c, 0x65, 0x5f,
+	0x64, 0x65, 0x66, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09,
+	0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0e, 0x72, 0x75, 0x6c, 0x65, 0x44, 0x65, 0x66, 0x69, 0x6e,
+	0x69, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x03, 0x62, 0x75, 0x67, 0x18, 0x05, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x19, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x41, 0x73, 0x73, 0x6f, 0x63, 0x69, 0x61, 0x74, 0x65, 0x64, 0x42, 0x75, 0x67, 0x42, 0x03, 0xe0,
+	0x41, 0x02, 0x52, 0x03, 0x62, 0x75, 0x67, 0x12, 0x1b, 0x0a, 0x09, 0x69, 0x73, 0x5f, 0x61, 0x63,
+	0x74, 0x69, 0x76, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x69, 0x73, 0x41, 0x63,
+	0x74, 0x69, 0x76, 0x65, 0x12, 0x26, 0x0a, 0x0f, 0x69, 0x73, 0x5f, 0x6d, 0x61, 0x6e, 0x61, 0x67,
+	0x69, 0x6e, 0x67, 0x5f, 0x62, 0x75, 0x67, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0d, 0x69,
+	0x73, 0x4d, 0x61, 0x6e, 0x61, 0x67, 0x69, 0x6e, 0x67, 0x42, 0x75, 0x67, 0x12, 0x3c, 0x0a, 0x0e,
+	0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x07,
+	0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x49, 0x64, 0x52, 0x0d, 0x73, 0x6f, 0x75,
+	0x72, 0x63, 0x65, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x12, 0x40, 0x0a, 0x0b, 0x63, 0x72,
+	0x65, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75,
+	0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x03, 0xe0, 0x41, 0x03,
+	0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x24, 0x0a, 0x0b,
+	0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28,
+	0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x55, 0x73,
+	0x65, 0x72, 0x12, 0x49, 0x0a, 0x10, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74,
+	0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67,
+	0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54,
+	0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0e, 0x6c,
+	0x61, 0x73, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x2d, 0x0a,
+	0x10, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x75, 0x73, 0x65,
+	0x72, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x03, 0x52, 0x0e, 0x6c, 0x61,
+	0x73, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x55, 0x73, 0x65, 0x72, 0x12, 0x5c, 0x0a, 0x1a,
+	0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x75,
+	0x70, 0x64, 0x61, 0x74, 0x65, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x42, 0x03, 0xe0, 0x41,
+	0x03, 0x52, 0x17, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x4c, 0x61, 0x73, 0x74,
+	0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x65, 0x74,
+	0x61, 0x67, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x22, 0x29,
+	0x0a, 0x0e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03,
+	0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x2f, 0x0a, 0x10, 0x4c, 0x69, 0x73,
+	0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a,
+	0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0,
+	0x41, 0x02, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x22, 0x3b, 0x0a, 0x11, 0x4c, 0x69,
+	0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+	0x26, 0x0a, 0x05, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x75, 0x6c, 0x65,
+	0x52, 0x05, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x22, 0x5b, 0x0a, 0x11, 0x43, 0x72, 0x65, 0x61, 0x74,
+	0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x06,
+	0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41,
+	0x02, 0x52, 0x06, 0x70, 0x61, 0x72, 0x65, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x04, 0x72, 0x75, 0x6c,
+	0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04,
+	0x72, 0x75, 0x6c, 0x65, 0x22, 0x8f, 0x01, 0x0a, 0x11, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52,
+	0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x29, 0x0a, 0x04, 0x72, 0x75,
+	0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52,
+	0x04, 0x72, 0x75, 0x6c, 0x65, 0x12, 0x3b, 0x0a, 0x0b, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x5f,
+	0x6d, 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x46, 0x69, 0x65,
+	0x6c, 0x64, 0x4d, 0x61, 0x73, 0x6b, 0x52, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61,
+	0x73, 0x6b, 0x12, 0x12, 0x0a, 0x04, 0x65, 0x74, 0x61, 0x67, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x04, 0x65, 0x74, 0x61, 0x67, 0x22, 0x44, 0x0a, 0x10, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70,
+	0x42, 0x75, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x06, 0x73, 0x79,
+	0x73, 0x74, 0x65, 0x6d, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52,
+	0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, 0x22, 0x29, 0x0a, 0x11,
+	0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x42, 0x75, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x12, 0x14, 0x0a, 0x05, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09,
+	0x52, 0x05, 0x72, 0x75, 0x6c, 0x65, 0x73, 0x32, 0xcb, 0x02, 0x0a, 0x05, 0x52, 0x75, 0x6c, 0x65,
+	0x73, 0x12, 0x35, 0x0a, 0x03, 0x47, 0x65, 0x74, 0x12, 0x1a, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x22, 0x00, 0x12, 0x45, 0x0a, 0x04, 0x4c, 0x69, 0x73, 0x74,
+	0x12, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69,
+	0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74,
+	0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12,
+	0x3b, 0x0a, 0x06, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c,
+	0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x22, 0x00, 0x12, 0x3b, 0x0a, 0x06,
+	0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x10, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x2e, 0x52, 0x75, 0x6c, 0x65, 0x22, 0x00, 0x12, 0x4a, 0x0a, 0x09, 0x4c, 0x6f, 0x6f,
+	0x6b, 0x75, 0x70, 0x42, 0x75, 0x67, 0x12, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x42, 0x75, 0x67, 0x52, 0x65, 0x71,
+	0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x2e, 0x4c, 0x6f, 0x6f, 0x6b, 0x75, 0x70, 0x42, 0x75, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f,
+	0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61,
+	0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69,
+	0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_rules_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_rules_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_infra_appengine_weetbix_proto_v1_rules_proto_goTypes = []interface{}{
+	(*Rule)(nil),                  // 0: weetbix.v1.Rule
+	(*GetRuleRequest)(nil),        // 1: weetbix.v1.GetRuleRequest
+	(*ListRulesRequest)(nil),      // 2: weetbix.v1.ListRulesRequest
+	(*ListRulesResponse)(nil),     // 3: weetbix.v1.ListRulesResponse
+	(*CreateRuleRequest)(nil),     // 4: weetbix.v1.CreateRuleRequest
+	(*UpdateRuleRequest)(nil),     // 5: weetbix.v1.UpdateRuleRequest
+	(*LookupBugRequest)(nil),      // 6: weetbix.v1.LookupBugRequest
+	(*LookupBugResponse)(nil),     // 7: weetbix.v1.LookupBugResponse
+	(*AssociatedBug)(nil),         // 8: weetbix.v1.AssociatedBug
+	(*ClusterId)(nil),             // 9: weetbix.v1.ClusterId
+	(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
+	(*fieldmaskpb.FieldMask)(nil), // 11: google.protobuf.FieldMask
+}
+var file_infra_appengine_weetbix_proto_v1_rules_proto_depIdxs = []int32{
+	8,  // 0: weetbix.v1.Rule.bug:type_name -> weetbix.v1.AssociatedBug
+	9,  // 1: weetbix.v1.Rule.source_cluster:type_name -> weetbix.v1.ClusterId
+	10, // 2: weetbix.v1.Rule.create_time:type_name -> google.protobuf.Timestamp
+	10, // 3: weetbix.v1.Rule.last_update_time:type_name -> google.protobuf.Timestamp
+	10, // 4: weetbix.v1.Rule.predicate_last_update_time:type_name -> google.protobuf.Timestamp
+	0,  // 5: weetbix.v1.ListRulesResponse.rules:type_name -> weetbix.v1.Rule
+	0,  // 6: weetbix.v1.CreateRuleRequest.rule:type_name -> weetbix.v1.Rule
+	0,  // 7: weetbix.v1.UpdateRuleRequest.rule:type_name -> weetbix.v1.Rule
+	11, // 8: weetbix.v1.UpdateRuleRequest.update_mask:type_name -> google.protobuf.FieldMask
+	1,  // 9: weetbix.v1.Rules.Get:input_type -> weetbix.v1.GetRuleRequest
+	2,  // 10: weetbix.v1.Rules.List:input_type -> weetbix.v1.ListRulesRequest
+	4,  // 11: weetbix.v1.Rules.Create:input_type -> weetbix.v1.CreateRuleRequest
+	5,  // 12: weetbix.v1.Rules.Update:input_type -> weetbix.v1.UpdateRuleRequest
+	6,  // 13: weetbix.v1.Rules.LookupBug:input_type -> weetbix.v1.LookupBugRequest
+	0,  // 14: weetbix.v1.Rules.Get:output_type -> weetbix.v1.Rule
+	3,  // 15: weetbix.v1.Rules.List:output_type -> weetbix.v1.ListRulesResponse
+	0,  // 16: weetbix.v1.Rules.Create:output_type -> weetbix.v1.Rule
+	0,  // 17: weetbix.v1.Rules.Update:output_type -> weetbix.v1.Rule
+	7,  // 18: weetbix.v1.Rules.LookupBug:output_type -> weetbix.v1.LookupBugResponse
+	14, // [14:19] is the sub-list for method output_type
+	9,  // [9:14] is the sub-list for method input_type
+	9,  // [9:9] is the sub-list for extension type_name
+	9,  // [9:9] is the sub-list for extension extendee
+	0,  // [0:9] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_rules_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_rules_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_rules_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_v1_common_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*Rule); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*GetRuleRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListRulesRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*ListRulesResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*CreateRuleRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*UpdateRuleRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*LookupBugRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*LookupBugResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_rules_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   8,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_rules_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_rules_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_rules_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_rules_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_rules_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_rules_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_rules_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// RulesClient is the client API for Rules service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type RulesClient interface {
+	// Retrieves a rule.
+	// Designed to conform to https://google.aip.dev/131.
+	Get(ctx context.Context, in *GetRuleRequest, opts ...grpc.CallOption) (*Rule, error)
+	// Lists rules.
+	// TODO: implement pagination to make this
+	// RPC compliant with https://google.aip.dev/132.
+	// This RPC is incomplete. Future breaking changes are
+	// expressly flagged.
+	List(ctx context.Context, in *ListRulesRequest, opts ...grpc.CallOption) (*ListRulesResponse, error)
+	// Creates a new rule.
+	// Designed to conform to https://google.aip.dev/133.
+	Create(ctx context.Context, in *CreateRuleRequest, opts ...grpc.CallOption) (*Rule, error)
+	// Updates a rule.
+	// Designed to conform to https://google.aip.dev/134.
+	Update(ctx context.Context, in *UpdateRuleRequest, opts ...grpc.CallOption) (*Rule, error)
+	// Looks up the rule associated with a given bug, without knowledge
+	// of the Weetbix project the rule is in.
+	// Designed to conform to https://google.aip.dev/136.
+	LookupBug(ctx context.Context, in *LookupBugRequest, opts ...grpc.CallOption) (*LookupBugResponse, error)
+}
+type rulesPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewRulesPRPCClient(client *prpc.Client) RulesClient {
+	return &rulesPRPCClient{client}
+}
+
+func (c *rulesPRPCClient) Get(ctx context.Context, in *GetRuleRequest, opts ...grpc.CallOption) (*Rule, error) {
+	out := new(Rule)
+	err := c.client.Call(ctx, "weetbix.v1.Rules", "Get", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesPRPCClient) List(ctx context.Context, in *ListRulesRequest, opts ...grpc.CallOption) (*ListRulesResponse, error) {
+	out := new(ListRulesResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Rules", "List", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesPRPCClient) Create(ctx context.Context, in *CreateRuleRequest, opts ...grpc.CallOption) (*Rule, error) {
+	out := new(Rule)
+	err := c.client.Call(ctx, "weetbix.v1.Rules", "Create", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesPRPCClient) Update(ctx context.Context, in *UpdateRuleRequest, opts ...grpc.CallOption) (*Rule, error) {
+	out := new(Rule)
+	err := c.client.Call(ctx, "weetbix.v1.Rules", "Update", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesPRPCClient) LookupBug(ctx context.Context, in *LookupBugRequest, opts ...grpc.CallOption) (*LookupBugResponse, error) {
+	out := new(LookupBugResponse)
+	err := c.client.Call(ctx, "weetbix.v1.Rules", "LookupBug", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type rulesClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewRulesClient(cc grpc.ClientConnInterface) RulesClient {
+	return &rulesClient{cc}
+}
+
+func (c *rulesClient) Get(ctx context.Context, in *GetRuleRequest, opts ...grpc.CallOption) (*Rule, error) {
+	out := new(Rule)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Rules/Get", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesClient) List(ctx context.Context, in *ListRulesRequest, opts ...grpc.CallOption) (*ListRulesResponse, error) {
+	out := new(ListRulesResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Rules/List", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesClient) Create(ctx context.Context, in *CreateRuleRequest, opts ...grpc.CallOption) (*Rule, error) {
+	out := new(Rule)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Rules/Create", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesClient) Update(ctx context.Context, in *UpdateRuleRequest, opts ...grpc.CallOption) (*Rule, error) {
+	out := new(Rule)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Rules/Update", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *rulesClient) LookupBug(ctx context.Context, in *LookupBugRequest, opts ...grpc.CallOption) (*LookupBugResponse, error) {
+	out := new(LookupBugResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.Rules/LookupBug", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// RulesServer is the server API for Rules service.
+type RulesServer interface {
+	// Retrieves a rule.
+	// Designed to conform to https://google.aip.dev/131.
+	Get(context.Context, *GetRuleRequest) (*Rule, error)
+	// Lists rules.
+	// TODO: implement pagination to make this
+	// RPC compliant with https://google.aip.dev/132.
+	// This RPC is incomplete. Future breaking changes are
+	// expressly flagged.
+	List(context.Context, *ListRulesRequest) (*ListRulesResponse, error)
+	// Creates a new rule.
+	// Designed to conform to https://google.aip.dev/133.
+	Create(context.Context, *CreateRuleRequest) (*Rule, error)
+	// Updates a rule.
+	// Designed to conform to https://google.aip.dev/134.
+	Update(context.Context, *UpdateRuleRequest) (*Rule, error)
+	// Looks up the rule associated with a given bug, without knowledge
+	// of the Weetbix project the rule is in.
+	// Designed to conform to https://google.aip.dev/136.
+	LookupBug(context.Context, *LookupBugRequest) (*LookupBugResponse, error)
+}
+
+// UnimplementedRulesServer can be embedded to have forward compatible implementations.
+type UnimplementedRulesServer struct {
+}
+
+func (*UnimplementedRulesServer) Get(context.Context, *GetRuleRequest) (*Rule, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method Get not implemented")
+}
+func (*UnimplementedRulesServer) List(context.Context, *ListRulesRequest) (*ListRulesResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method List not implemented")
+}
+func (*UnimplementedRulesServer) Create(context.Context, *CreateRuleRequest) (*Rule, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method Create not implemented")
+}
+func (*UnimplementedRulesServer) Update(context.Context, *UpdateRuleRequest) (*Rule, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method Update not implemented")
+}
+func (*UnimplementedRulesServer) LookupBug(context.Context, *LookupBugRequest) (*LookupBugResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method LookupBug not implemented")
+}
+
+func RegisterRulesServer(s prpc.Registrar, srv RulesServer) {
+	s.RegisterService(&_Rules_serviceDesc, srv)
+}
+
+func _Rules_Get_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(GetRuleRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RulesServer).Get(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Rules/Get",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RulesServer).Get(ctx, req.(*GetRuleRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Rules_List_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(ListRulesRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RulesServer).List(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Rules/List",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RulesServer).List(ctx, req.(*ListRulesRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Rules_Create_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(CreateRuleRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RulesServer).Create(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Rules/Create",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RulesServer).Create(ctx, req.(*CreateRuleRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Rules_Update_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(UpdateRuleRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RulesServer).Update(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Rules/Update",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RulesServer).Update(ctx, req.(*UpdateRuleRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _Rules_LookupBug_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(LookupBugRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(RulesServer).LookupBug(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.Rules/LookupBug",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(RulesServer).LookupBug(ctx, req.(*LookupBugRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _Rules_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.v1.Rules",
+	HandlerType: (*RulesServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "Get",
+			Handler:    _Rules_Get_Handler,
+		},
+		{
+			MethodName: "List",
+			Handler:    _Rules_List_Handler,
+		},
+		{
+			MethodName: "Create",
+			Handler:    _Rules_Create_Handler,
+		},
+		{
+			MethodName: "Update",
+			Handler:    _Rules_Update_Handler,
+		},
+		{
+			MethodName: "LookupBug",
+			Handler:    _Rules_LookupBug_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/proto/v1/rules.proto",
+}
diff --git a/analysis/proto/v1/rules.proto b/analysis/proto/v1/rules.proto
new file mode 100644
index 0000000..864d1d5
--- /dev/null
+++ b/analysis/proto/v1/rules.proto
@@ -0,0 +1,188 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+import "google/api/field_behavior.proto";
+import "google/protobuf/field_mask.proto";
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// Provides methods to manipulate rules in Weetbix, used to associate
+// failures with bugs.
+service Rules {
+  // Retrieves a rule.
+  // Designed to conform to https://google.aip.dev/131.
+  rpc Get(GetRuleRequest) returns (Rule) {};
+
+  // Lists rules.
+  // TODO: implement pagination to make this
+  // RPC compliant with https://google.aip.dev/132.
+  // This RPC is incomplete. Future breaking changes are
+  // expressly flagged.
+  rpc List(ListRulesRequest) returns (ListRulesResponse) {};
+
+  // Creates a new rule.
+  // Designed to conform to https://google.aip.dev/133.
+  rpc Create(CreateRuleRequest) returns (Rule) {};
+
+  // Updates a rule.
+  // Designed to conform to https://google.aip.dev/134.
+  rpc Update(UpdateRuleRequest) returns (Rule) {};
+
+  // Looks up the rule associated with a given bug, without knowledge
+  // of the Weetbix project the rule is in.
+  // Designed to conform to https://google.aip.dev/136.
+  rpc LookupBug(LookupBugRequest) returns (LookupBugResponse) {};
+}
+
+// A rule associating failures with a bug.
+// Next ID: 15.
+message Rule {
+  // Can be used to refer to this rule, e.g. in RulesService.Get RPC.
+  // Format: projects/{project}/rules/{rule_id}.
+  // See also https://google.aip.dev/122.
+  string name = 1;
+
+  // The LUCI Project for which this rule is defined.
+  string project = 2
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The unique identifier for the failure association rule,
+  // as 32 lowercase hexadecimal characters.
+  string rule_id = 3
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The rule predicate, defining which failures are being associated.
+  string rule_definition = 4
+    [(google.api.field_behavior) = REQUIRED];
+
+  // The bug that the failures are associated with.
+  weetbix.v1.AssociatedBug bug = 5
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Whether the bug should be updated by Weetbix, and whether failures
+  // should still be matched against the rule.
+  bool is_active = 6;
+
+  // Whether Weetbix should manage the priority and verified status
+  // of the associated bug based on the impact established via this rule.
+  bool is_managing_bug = 14;
+
+  // The suggested cluster this rule was created from (if any).
+  // Until re-clustering is complete and has reduced the residual impact
+  // of the source cluster, this cluster ID tells bug filing to ignore
+  // the source cluster when determining whether new bugs need to be filed.
+  weetbix.v1.ClusterId source_cluster = 7;
+
+  // The time the rule was created.
+  google.protobuf.Timestamp create_time = 8
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The user which created the rule.
+  string create_user = 9
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The time the rule was last updated.
+  google.protobuf.Timestamp last_update_time = 10
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The user which last updated the rule.
+  string last_update_user = 11
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // The time the rule was last updated in a way that caused the
+  // matched failures to change, i.e. because of a change to rule_definition
+  // or is_active. (By contrast, updating the associated bug does NOT change
+  // the matched failures, so does NOT update this field.)
+  // Output only.
+  google.protobuf.Timestamp predicate_last_update_time = 13
+    [(google.api.field_behavior) = OUTPUT_ONLY];
+
+  // This checksum is computed by the server based on the value of other
+  // fields, and may be sent on update requests to ensure the client
+  // has an up-to-date value before proceeding.
+  // See also https://google.aip.dev/154.
+  string etag = 12;
+}
+
+message GetRuleRequest {
+  // The name of the rule to retrieve.
+  // Format: projects/{project}/rules/{rule_id}.
+  string name = 1
+    [(google.api.field_behavior) = REQUIRED];
+}
+
+message ListRulesRequest {
+  // The parent, which owns this collection of rules.
+  // Format: projects/{project}.
+  string parent = 1
+    [(google.api.field_behavior) = REQUIRED];
+}
+
+message ListRulesResponse {
+  // The rules.
+  repeated Rule rules = 1;
+}
+
+message CreateRuleRequest {
+  // The parent resource where the rule will be created.
+  // Format: projects/{project}.
+  string parent = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // The rule to create.
+  Rule rule = 2
+    [(google.api.field_behavior) = REQUIRED];
+}
+
+message UpdateRuleRequest {
+  // The rule to update.
+  //
+  // The rule's `name` field is used to identify the book to update.
+  // Format: projects/{project}/rules/{rule_id}.
+  Rule rule = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // The list of fields to update.
+  google.protobuf.FieldMask update_mask = 2;
+
+  // The current etag of the rule.
+  // If an etag is provided and does not match the current etag of the rule,
+  // update will be blocked and an ABORTED error will be returned.
+  string etag = 3;
+}
+
+message LookupBugRequest {
+  // System is the bug tracking system of the bug. This is either
+  // "monorail" or "buganizer".
+  string system = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Id is the bug tracking system-specific identity of the bug.
+  // For monorail, the scheme is {project}/{numeric_id}, for
+  // buganizer the scheme is {numeric_id}.
+  string id = 2
+    [(google.api.field_behavior) = REQUIRED];
+}
+
+message LookupBugResponse {
+  // The rules corresponding to the requested bug.
+  // Format: projects/{project}/rules/{rule_id}.
+  repeated string rules = 2;
+}
\ No newline at end of file
diff --git a/analysis/proto/v1/rulesserver_dec.go b/analysis/proto/v1/rulesserver_dec.go
new file mode 100644
index 0000000..ae20f86
--- /dev/null
+++ b/analysis/proto/v1/rulesserver_dec.go
@@ -0,0 +1,109 @@
+// Code generated by svcdec; DO NOT EDIT.
+
+package weetbixpb
+
+import (
+	"context"
+
+	proto "github.com/golang/protobuf/proto"
+)
+
+type DecoratedRules struct {
+	// Service is the service to decorate.
+	Service RulesServer
+	// Prelude is called for each method before forwarding the call to Service.
+	// If Prelude returns an error, then the call is skipped and the error is
+	// processed via the Postlude (if one is defined), or it is returned directly.
+	Prelude func(ctx context.Context, methodName string, req proto.Message) (context.Context, error)
+	// Postlude is called for each method after Service has processed the call, or
+	// after the Prelude has returned an error. This takes the the Service's
+	// response proto (which may be nil) and/or any error. The decorated
+	// service will return the response (possibly mutated) and error that Postlude
+	// returns.
+	Postlude func(ctx context.Context, methodName string, rsp proto.Message, err error) error
+}
+
+func (s *DecoratedRules) Get(ctx context.Context, req *GetRuleRequest) (rsp *Rule, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "Get", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.Get(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "Get", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedRules) List(ctx context.Context, req *ListRulesRequest) (rsp *ListRulesResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "List", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.List(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "List", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedRules) Create(ctx context.Context, req *CreateRuleRequest) (rsp *Rule, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "Create", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.Create(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "Create", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedRules) Update(ctx context.Context, req *UpdateRuleRequest) (rsp *Rule, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "Update", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.Update(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "Update", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedRules) LookupBug(ctx context.Context, req *LookupBugRequest) (rsp *LookupBugResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "LookupBug", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.LookupBug(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "LookupBug", rsp, err)
+	}
+	return
+}
diff --git a/analysis/proto/v1/test_history.pb.go b/analysis/proto/v1/test_history.pb.go
new file mode 100644
index 0000000..7ec077a
--- /dev/null
+++ b/analysis/proto/v1/test_history.pb.go
@@ -0,0 +1,1478 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/test_history.proto
+
+package weetbixpb
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	_ "google.golang.org/genproto/googleapis/api/annotations"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	durationpb "google.golang.org/protobuf/types/known/durationpb"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// A request message for `TestHistory.Query` RPC.
+type QueryTestHistoryRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Required. The LUCI Project of the test results.
+	// I.e. For a result to be part of the history, it needs to be contained
+	// transitively by an invocation in this project.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// Required. The test ID to query the history from.
+	TestId string `protobuf:"bytes,2,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Required. A test verdict in the response must satisfy this predicate.
+	Predicate *TestVerdictPredicate `protobuf:"bytes,3,opt,name=predicate,proto3" json:"predicate,omitempty"`
+	// The maximum number of entries to return.
+	//
+	// The service may return fewer than this value.
+	// If unspecified, at most 100 variants will be returned.
+	// The maximum value is 1000; values above 1000 will be coerced to 1000.
+	PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to the next call MUST
+	// match the call that provided the page token.
+	PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *QueryTestHistoryRequest) Reset() {
+	*x = QueryTestHistoryRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestHistoryRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestHistoryRequest) ProtoMessage() {}
+
+func (x *QueryTestHistoryRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestHistoryRequest.ProtoReflect.Descriptor instead.
+func (*QueryTestHistoryRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *QueryTestHistoryRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *QueryTestHistoryRequest) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *QueryTestHistoryRequest) GetPredicate() *TestVerdictPredicate {
+	if x != nil {
+		return x.Predicate
+	}
+	return nil
+}
+
+func (x *QueryTestHistoryRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// A response message for `TestHistory.Query` RPC.
+type QueryTestHistoryResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The list of test verdicts.
+	// Test verdicts will be ordered by `partition_time` DESC, `variant_hash` ASC,
+	// `invocation_id` ASC.
+	Verdicts []*TestVerdict `protobuf:"bytes,1,rep,name=verdicts,proto3" json:"verdicts,omitempty"`
+	// This field will be set if there are more results to return.
+	// To get the next page of data, send the same request again, but include this
+	// token.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *QueryTestHistoryResponse) Reset() {
+	*x = QueryTestHistoryResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestHistoryResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestHistoryResponse) ProtoMessage() {}
+
+func (x *QueryTestHistoryResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestHistoryResponse.ProtoReflect.Descriptor instead.
+func (*QueryTestHistoryResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *QueryTestHistoryResponse) GetVerdicts() []*TestVerdict {
+	if x != nil {
+		return x.Verdicts
+	}
+	return nil
+}
+
+func (x *QueryTestHistoryResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// A request message for `TestHistory.QueryStats` RPC.
+type QueryTestHistoryStatsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Required. The LUCI Project of the test results.
+	// I.e. For a result to be part of the history, it needs to be contained
+	// transitively by an invocation in this project.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// Required. The test ID to query the history from.
+	TestId string `protobuf:"bytes,2,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Required. A test verdict in the response must satisfy this predicate.
+	Predicate *TestVerdictPredicate `protobuf:"bytes,3,opt,name=predicate,proto3" json:"predicate,omitempty"`
+	// The maximum number of entries to return.
+	//
+	// The service may return fewer than this value.
+	// If unspecified, at most 100 variants will be returned.
+	// The maximum value is 1000; values above 1000 will be coerced to 1000.
+	PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to the next call
+	// MUST match the call that provided the page token.
+	PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *QueryTestHistoryStatsRequest) Reset() {
+	*x = QueryTestHistoryStatsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestHistoryStatsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestHistoryStatsRequest) ProtoMessage() {}
+
+func (x *QueryTestHistoryStatsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestHistoryStatsRequest.ProtoReflect.Descriptor instead.
+func (*QueryTestHistoryStatsRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *QueryTestHistoryStatsRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *QueryTestHistoryStatsRequest) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *QueryTestHistoryStatsRequest) GetPredicate() *TestVerdictPredicate {
+	if x != nil {
+		return x.Predicate
+	}
+	return nil
+}
+
+func (x *QueryTestHistoryStatsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryStatsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// A response message for `TestHistory.QueryStats` RPC.
+type QueryTestHistoryStatsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The list of test verdict groups. Test verdicts will be grouped and ordered
+	// by `partition_date` DESC, `variant_hash` ASC.
+	Groups []*QueryTestHistoryStatsResponse_Group `protobuf:"bytes,1,rep,name=groups,proto3" json:"groups,omitempty"`
+	// This field will be set if there are more results to return.
+	// To get the next page of data, send the same request again, but include this
+	// token.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *QueryTestHistoryStatsResponse) Reset() {
+	*x = QueryTestHistoryStatsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestHistoryStatsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestHistoryStatsResponse) ProtoMessage() {}
+
+func (x *QueryTestHistoryStatsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestHistoryStatsResponse.ProtoReflect.Descriptor instead.
+func (*QueryTestHistoryStatsResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *QueryTestHistoryStatsResponse) GetGroups() []*QueryTestHistoryStatsResponse_Group {
+	if x != nil {
+		return x.Groups
+	}
+	return nil
+}
+
+func (x *QueryTestHistoryStatsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// A request message for the `QueryVariants` RPC.
+type QueryVariantsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Required. The LUCI project to query the variants from.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// Required. The test ID to query the variants from.
+	TestId string `protobuf:"bytes,2,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Optional. The project-scoped realm to query the variants from.
+	// This is the realm without the "<project>:" prefix.
+	//
+	// When specified, only the test variants found in the matching realm will be
+	// returned.
+	SubRealm string `protobuf:"bytes,3,opt,name=sub_realm,json=subRealm,proto3" json:"sub_realm,omitempty"`
+	// Optional. When specified, only variant matches this predicate will be
+	// returned.
+	VariantPredicate *VariantPredicate `protobuf:"bytes,6,opt,name=variant_predicate,json=variantPredicate,proto3" json:"variant_predicate,omitempty"`
+	// The maximum number of variants to return.
+	//
+	// The service may return fewer than this value.
+	// If unspecified, at most 100 variants will be returned.
+	// The maximum value is 1000; values above 1000 will be coerced to 1000.
+	PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `QueryVariants` call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to `QueryVariants` MUST
+	// match the call that provided the page token.
+	PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *QueryVariantsRequest) Reset() {
+	*x = QueryVariantsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryVariantsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryVariantsRequest) ProtoMessage() {}
+
+func (x *QueryVariantsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryVariantsRequest.ProtoReflect.Descriptor instead.
+func (*QueryVariantsRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *QueryVariantsRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *QueryVariantsRequest) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *QueryVariantsRequest) GetSubRealm() string {
+	if x != nil {
+		return x.SubRealm
+	}
+	return ""
+}
+
+func (x *QueryVariantsRequest) GetVariantPredicate() *VariantPredicate {
+	if x != nil {
+		return x.VariantPredicate
+	}
+	return nil
+}
+
+func (x *QueryVariantsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *QueryVariantsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// A response message for the `QueryVariants` RPC.
+type QueryVariantsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A list of variants. Ordered by variant hash.
+	Variants []*QueryVariantsResponse_VariantInfo `protobuf:"bytes,1,rep,name=variants,proto3" json:"variants,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there were no subsequent pages at the time of
+	// request.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *QueryVariantsResponse) Reset() {
+	*x = QueryVariantsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryVariantsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryVariantsResponse) ProtoMessage() {}
+
+func (x *QueryVariantsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryVariantsResponse.ProtoReflect.Descriptor instead.
+func (*QueryVariantsResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *QueryVariantsResponse) GetVariants() []*QueryVariantsResponse_VariantInfo {
+	if x != nil {
+		return x.Variants
+	}
+	return nil
+}
+
+func (x *QueryVariantsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+// A request message for the `QueryTests` RPC.
+type QueryTestsRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Required. The LUCI project to query the tests from.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// Required. Only tests that contain the substring will be returned.
+	TestIdSubstring string `protobuf:"bytes,2,opt,name=test_id_substring,json=testIdSubstring,proto3" json:"test_id_substring,omitempty"`
+	// Optional. The project-scoped realm to query the variants from.
+	// This is the realm without the "<project>:" prefix.
+	//
+	// When specified, only the tests found in the matching realm will be
+	// returned.
+	SubRealm string `protobuf:"bytes,3,opt,name=sub_realm,json=subRealm,proto3" json:"sub_realm,omitempty"`
+	// The maximum number of test IDs to return.
+	//
+	// The service may return fewer than this value.
+	// If unspecified, at most 100 test IDs will be returned.
+	// The maximum value is 1000; values above 1000 will be coerced to 1000.
+	PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
+	// A page token, received from a previous `QueryTests` call.
+	// Provide this to retrieve the subsequent page.
+	//
+	// When paginating, all other parameters provided to `QueryTests` MUST
+	// match the call that provided the page token.
+	PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
+}
+
+func (x *QueryTestsRequest) Reset() {
+	*x = QueryTestsRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestsRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestsRequest) ProtoMessage() {}
+
+func (x *QueryTestsRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestsRequest.ProtoReflect.Descriptor instead.
+func (*QueryTestsRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *QueryTestsRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *QueryTestsRequest) GetTestIdSubstring() string {
+	if x != nil {
+		return x.TestIdSubstring
+	}
+	return ""
+}
+
+func (x *QueryTestsRequest) GetSubRealm() string {
+	if x != nil {
+		return x.SubRealm
+	}
+	return ""
+}
+
+func (x *QueryTestsRequest) GetPageSize() int32 {
+	if x != nil {
+		return x.PageSize
+	}
+	return 0
+}
+
+func (x *QueryTestsRequest) GetPageToken() string {
+	if x != nil {
+		return x.PageToken
+	}
+	return ""
+}
+
+// A response message for the `QueryTests` RPC.
+type QueryTestsResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A list of test Ids. Ordered alphabetically.
+	TestIds []string `protobuf:"bytes,1,rep,name=test_ids,json=testIds,proto3" json:"test_ids,omitempty"`
+	// A token, which can be sent as `page_token` to retrieve the next page.
+	// If this field is omitted, there were no subsequent pages at the time of
+	// request.
+	NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
+}
+
+func (x *QueryTestsResponse) Reset() {
+	*x = QueryTestsResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestsResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestsResponse) ProtoMessage() {}
+
+func (x *QueryTestsResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestsResponse.ProtoReflect.Descriptor instead.
+func (*QueryTestsResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *QueryTestsResponse) GetTestIds() []string {
+	if x != nil {
+		return x.TestIds
+	}
+	return nil
+}
+
+func (x *QueryTestsResponse) GetNextPageToken() string {
+	if x != nil {
+		return x.NextPageToken
+	}
+	return ""
+}
+
+type QueryTestHistoryStatsResponse_Group struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The start time of this group.
+	// Test verdicts that are paritioned in the 24 hours following this
+	// timestamp are captured in this group.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// The hash of the variant.
+	VariantHash string `protobuf:"bytes,2,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// The number of unexpected test verdicts in the group.
+	UnexpectedCount int32 `protobuf:"varint,3,opt,name=unexpected_count,json=unexpectedCount,proto3" json:"unexpected_count,omitempty"`
+	// The number of unexpectedly skipped test verdicts in the group.
+	UnexpectedlySkippedCount int32 `protobuf:"varint,4,opt,name=unexpectedly_skipped_count,json=unexpectedlySkippedCount,proto3" json:"unexpectedly_skipped_count,omitempty"`
+	// The number of flaky test verdicts in the group.
+	FlakyCount int32 `protobuf:"varint,5,opt,name=flaky_count,json=flakyCount,proto3" json:"flaky_count,omitempty"`
+	// The number of exonerated test verdicts in the group.
+	ExoneratedCount int32 `protobuf:"varint,6,opt,name=exonerated_count,json=exoneratedCount,proto3" json:"exonerated_count,omitempty"`
+	// The number of expected test verdicts in the group.
+	ExpectedCount int32 `protobuf:"varint,7,opt,name=expected_count,json=expectedCount,proto3" json:"expected_count,omitempty"`
+	// The average duration of passing test results in the group.
+	PassedAvgDuration *durationpb.Duration `protobuf:"bytes,8,opt,name=passed_avg_duration,json=passedAvgDuration,proto3" json:"passed_avg_duration,omitempty"`
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) Reset() {
+	*x = QueryTestHistoryStatsResponse_Group{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[8]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestHistoryStatsResponse_Group) ProtoMessage() {}
+
+func (x *QueryTestHistoryStatsResponse_Group) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[8]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestHistoryStatsResponse_Group.ProtoReflect.Descriptor instead.
+func (*QueryTestHistoryStatsResponse_Group) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{3, 0}
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetUnexpectedCount() int32 {
+	if x != nil {
+		return x.UnexpectedCount
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetUnexpectedlySkippedCount() int32 {
+	if x != nil {
+		return x.UnexpectedlySkippedCount
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetFlakyCount() int32 {
+	if x != nil {
+		return x.FlakyCount
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetExoneratedCount() int32 {
+	if x != nil {
+		return x.ExoneratedCount
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetExpectedCount() int32 {
+	if x != nil {
+		return x.ExpectedCount
+	}
+	return 0
+}
+
+func (x *QueryTestHistoryStatsResponse_Group) GetPassedAvgDuration() *durationpb.Duration {
+	if x != nil {
+		return x.PassedAvgDuration
+	}
+	return nil
+}
+
+// Contains the variant definition and its hash.
+type QueryVariantsResponse_VariantInfo struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The hash of the variant.
+	VariantHash string `protobuf:"bytes,1,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// The definition of the variant.
+	Variant *Variant `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"`
+}
+
+func (x *QueryVariantsResponse_VariantInfo) Reset() {
+	*x = QueryVariantsResponse_VariantInfo{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[9]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryVariantsResponse_VariantInfo) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryVariantsResponse_VariantInfo) ProtoMessage() {}
+
+func (x *QueryVariantsResponse_VariantInfo) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[9]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryVariantsResponse_VariantInfo.ProtoReflect.Descriptor instead.
+func (*QueryVariantsResponse_VariantInfo) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP(), []int{5, 0}
+}
+
+func (x *QueryVariantsResponse_VariantInfo) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *QueryVariantsResponse_VariantInfo) GetVariant() *Variant {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_proto_v1_test_history_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDesc = []byte{
+	0x0a, 0x33, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69,
+	0x65, 0x6c, 0x64, 0x5f, 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e,
+	0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x30, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67,
+	0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x2e, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x33, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65,
+	0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x64,
+	0x69, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xd7, 0x01, 0x0a, 0x17, 0x51, 0x75,
+	0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
+	0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x70, 0x72, 0x6f,
+	0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18,
+	0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74,
+	0x49, 0x64, 0x12, 0x43, 0x0a, 0x09, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x50, 0x72,
+	0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x70, 0x72,
+	0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f,
+	0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65,
+	0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b,
+	0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f,
+	0x6b, 0x65, 0x6e, 0x22, 0x77, 0x0a, 0x18, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74,
+	0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
+	0x33, 0x0a, 0x08, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x17, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54,
+	0x65, 0x73, 0x74, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x52, 0x08, 0x76, 0x65, 0x72, 0x64,
+	0x69, 0x63, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67,
+	0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e,
+	0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xdc, 0x01, 0x0a,
+	0x1c, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72,
+	0x79, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
+	0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03,
+	0xe0, 0x41, 0x02, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a, 0x07,
+	0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0,
+	0x41, 0x02, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x43, 0x0a, 0x09, 0x70, 0x72,
+	0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56,
+	0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x42,
+	0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x12,
+	0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a,
+	0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09,
+	0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xa7, 0x04, 0x0a, 0x1d,
+	0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79,
+	0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x47, 0x0a,
+	0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2f, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79,
+	0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x53, 0x74, 0x61, 0x74, 0x73,
+	0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x06,
+	0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70,
+	0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
+	0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0x94,
+	0x03, 0x0a, 0x05, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x41, 0x0a, 0x0e, 0x70, 0x61, 0x72, 0x74,
+	0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x70, 0x61,
+	0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, 0x76,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x29,
+	0x0a, 0x10, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75,
+	0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65,
+	0x63, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x3c, 0x0a, 0x1a, 0x75, 0x6e, 0x65,
+	0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x6c, 0x79, 0x5f, 0x73, 0x6b, 0x69, 0x70, 0x70, 0x65,
+	0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x18, 0x75,
+	0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x6c, 0x79, 0x53, 0x6b, 0x69, 0x70, 0x70,
+	0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x66, 0x6c, 0x61, 0x6b, 0x79,
+	0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x66, 0x6c,
+	0x61, 0x6b, 0x79, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x29, 0x0a, 0x10, 0x65, 0x78, 0x6f, 0x6e,
+	0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x06, 0x20, 0x01,
+	0x28, 0x05, 0x52, 0x0f, 0x65, 0x78, 0x6f, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x43, 0x6f,
+	0x75, 0x6e, 0x74, 0x12, 0x25, 0x0a, 0x0e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f,
+	0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x07, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0d, 0x65, 0x78, 0x70,
+	0x65, 0x63, 0x74, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x49, 0x0a, 0x13, 0x70, 0x61,
+	0x73, 0x73, 0x65, 0x64, 0x5f, 0x61, 0x76, 0x67, 0x5f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x52, 0x11, 0x70, 0x61, 0x73, 0x73, 0x65, 0x64, 0x41, 0x76, 0x67, 0x44, 0x75, 0x72,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0xf7, 0x01, 0x0a, 0x14, 0x51, 0x75, 0x65, 0x72, 0x79, 0x56,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d,
+	0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42,
+	0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x1c, 0x0a,
+	0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03,
+	0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73,
+	0x75, 0x62, 0x5f, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08,
+	0x73, 0x75, 0x62, 0x52, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x49, 0x0a, 0x11, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x5f, 0x70, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74, 0x65, 0x18, 0x06, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63, 0x61, 0x74,
+	0x65, 0x52, 0x10, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x50, 0x72, 0x65, 0x64, 0x69, 0x63,
+	0x61, 0x74, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65,
+	0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65,
+	0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22,
+	0xeb, 0x01, 0x0a, 0x15, 0x51, 0x75, 0x65, 0x72, 0x79, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x08, 0x76, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2d, 0x2e, 0x77, 0x65,
+	0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x56, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x56,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x08, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67,
+	0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e,
+	0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x1a, 0x5f, 0x0a, 0x0b,
+	0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x21, 0x0a, 0x0c, 0x76,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x2d,
+	0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32,
+	0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x56, 0x61, 0x72,
+	0x69, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x22, 0xbc, 0x01,
+	0x0a, 0x11, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01,
+	0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65,
+	0x63, 0x74, 0x12, 0x2f, 0x0a, 0x11, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x5f, 0x73, 0x75,
+	0x62, 0x73, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0,
+	0x41, 0x02, 0x52, 0x0f, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x53, 0x75, 0x62, 0x73, 0x74, 0x72,
+	0x69, 0x6e, 0x67, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x5f, 0x72, 0x65, 0x61, 0x6c, 0x6d,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x75, 0x62, 0x52, 0x65, 0x61, 0x6c, 0x6d,
+	0x12, 0x1b, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20,
+	0x01, 0x28, 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a,
+	0x0a, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x57, 0x0a, 0x12,
+	0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
+	0x73, 0x65, 0x12, 0x19, 0x0a, 0x08, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x01,
+	0x20, 0x03, 0x28, 0x09, 0x52, 0x07, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x73, 0x12, 0x26, 0x0a,
+	0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65,
+	0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x32, 0xef, 0x02, 0x0a, 0x0b, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69,
+	0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x54, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x23,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72,
+	0x79, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72,
+	0x79, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x63, 0x0a, 0x0a, 0x51,
+	0x75, 0x65, 0x72, 0x79, 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x28, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74,
+	0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72,
+	0x79, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00,
+	0x12, 0x56, 0x0a, 0x0d, 0x51, 0x75, 0x65, 0x72, 0x79, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x73, 0x12, 0x20, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51,
+	0x75, 0x65, 0x72, 0x79, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75,
+	0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x52, 0x65,
+	0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x4d, 0x0a, 0x0a, 0x51, 0x75, 0x65, 0x72,
+	0x79, 0x54, 0x65, 0x73, 0x74, 0x73, 0x12, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x73, 0x52, 0x65,
+	0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x73, 0x52, 0x65, 0x73,
+	0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61,
+	0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62,
+	0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_infra_appengine_weetbix_proto_v1_test_history_proto_goTypes = []interface{}{
+	(*QueryTestHistoryRequest)(nil),             // 0: weetbix.v1.QueryTestHistoryRequest
+	(*QueryTestHistoryResponse)(nil),            // 1: weetbix.v1.QueryTestHistoryResponse
+	(*QueryTestHistoryStatsRequest)(nil),        // 2: weetbix.v1.QueryTestHistoryStatsRequest
+	(*QueryTestHistoryStatsResponse)(nil),       // 3: weetbix.v1.QueryTestHistoryStatsResponse
+	(*QueryVariantsRequest)(nil),                // 4: weetbix.v1.QueryVariantsRequest
+	(*QueryVariantsResponse)(nil),               // 5: weetbix.v1.QueryVariantsResponse
+	(*QueryTestsRequest)(nil),                   // 6: weetbix.v1.QueryTestsRequest
+	(*QueryTestsResponse)(nil),                  // 7: weetbix.v1.QueryTestsResponse
+	(*QueryTestHistoryStatsResponse_Group)(nil), // 8: weetbix.v1.QueryTestHistoryStatsResponse.Group
+	(*QueryVariantsResponse_VariantInfo)(nil),   // 9: weetbix.v1.QueryVariantsResponse.VariantInfo
+	(*TestVerdictPredicate)(nil),                // 10: weetbix.v1.TestVerdictPredicate
+	(*TestVerdict)(nil),                         // 11: weetbix.v1.TestVerdict
+	(*VariantPredicate)(nil),                    // 12: weetbix.v1.VariantPredicate
+	(*timestamppb.Timestamp)(nil),               // 13: google.protobuf.Timestamp
+	(*durationpb.Duration)(nil),                 // 14: google.protobuf.Duration
+	(*Variant)(nil),                             // 15: weetbix.v1.Variant
+}
+var file_infra_appengine_weetbix_proto_v1_test_history_proto_depIdxs = []int32{
+	10, // 0: weetbix.v1.QueryTestHistoryRequest.predicate:type_name -> weetbix.v1.TestVerdictPredicate
+	11, // 1: weetbix.v1.QueryTestHistoryResponse.verdicts:type_name -> weetbix.v1.TestVerdict
+	10, // 2: weetbix.v1.QueryTestHistoryStatsRequest.predicate:type_name -> weetbix.v1.TestVerdictPredicate
+	8,  // 3: weetbix.v1.QueryTestHistoryStatsResponse.groups:type_name -> weetbix.v1.QueryTestHistoryStatsResponse.Group
+	12, // 4: weetbix.v1.QueryVariantsRequest.variant_predicate:type_name -> weetbix.v1.VariantPredicate
+	9,  // 5: weetbix.v1.QueryVariantsResponse.variants:type_name -> weetbix.v1.QueryVariantsResponse.VariantInfo
+	13, // 6: weetbix.v1.QueryTestHistoryStatsResponse.Group.partition_time:type_name -> google.protobuf.Timestamp
+	14, // 7: weetbix.v1.QueryTestHistoryStatsResponse.Group.passed_avg_duration:type_name -> google.protobuf.Duration
+	15, // 8: weetbix.v1.QueryVariantsResponse.VariantInfo.variant:type_name -> weetbix.v1.Variant
+	0,  // 9: weetbix.v1.TestHistory.Query:input_type -> weetbix.v1.QueryTestHistoryRequest
+	2,  // 10: weetbix.v1.TestHistory.QueryStats:input_type -> weetbix.v1.QueryTestHistoryStatsRequest
+	4,  // 11: weetbix.v1.TestHistory.QueryVariants:input_type -> weetbix.v1.QueryVariantsRequest
+	6,  // 12: weetbix.v1.TestHistory.QueryTests:input_type -> weetbix.v1.QueryTestsRequest
+	1,  // 13: weetbix.v1.TestHistory.Query:output_type -> weetbix.v1.QueryTestHistoryResponse
+	3,  // 14: weetbix.v1.TestHistory.QueryStats:output_type -> weetbix.v1.QueryTestHistoryStatsResponse
+	5,  // 15: weetbix.v1.TestHistory.QueryVariants:output_type -> weetbix.v1.QueryVariantsResponse
+	7,  // 16: weetbix.v1.TestHistory.QueryTests:output_type -> weetbix.v1.QueryTestsResponse
+	13, // [13:17] is the sub-list for method output_type
+	9,  // [9:13] is the sub-list for method input_type
+	9,  // [9:9] is the sub-list for extension type_name
+	9,  // [9:9] is the sub-list for extension extendee
+	0,  // [0:9] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_test_history_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_test_history_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_test_history_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_v1_common_proto_init()
+	file_infra_appengine_weetbix_proto_v1_predicate_proto_init()
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestHistoryRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestHistoryResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestHistoryStatsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestHistoryStatsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryVariantsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryVariantsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestsRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestsResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestHistoryStatsResponse_Group); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryVariantsResponse_VariantInfo); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   10,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_test_history_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_test_history_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_test_history_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_test_history_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_test_history_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_test_history_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_test_history_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// TestHistoryClient is the client API for TestHistory service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type TestHistoryClient interface {
+	// Retrieves test verdicts for a given test ID in a given project and in a
+	// given range of time.
+	// Accepts a test variant predicate to filter the verdicts.
+	Query(ctx context.Context, in *QueryTestHistoryRequest, opts ...grpc.CallOption) (*QueryTestHistoryResponse, error)
+	// Retrieves a summary of test verdicts for a given test ID in a given project
+	// and in a given range of times.
+	// Accepts a test variant predicate to filter the verdicts.
+	QueryStats(ctx context.Context, in *QueryTestHistoryStatsRequest, opts ...grpc.CallOption) (*QueryTestHistoryStatsResponse, error)
+	// Retrieves variants for a given test ID in a given project that were
+	// recorded in the past 90 days.
+	QueryVariants(ctx context.Context, in *QueryVariantsRequest, opts ...grpc.CallOption) (*QueryVariantsResponse, error)
+	// Finds test IDs that contain the given substring in a given project that
+	// were recorded in the past 90 days.
+	QueryTests(ctx context.Context, in *QueryTestsRequest, opts ...grpc.CallOption) (*QueryTestsResponse, error)
+}
+type testHistoryPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewTestHistoryPRPCClient(client *prpc.Client) TestHistoryClient {
+	return &testHistoryPRPCClient{client}
+}
+
+func (c *testHistoryPRPCClient) Query(ctx context.Context, in *QueryTestHistoryRequest, opts ...grpc.CallOption) (*QueryTestHistoryResponse, error) {
+	out := new(QueryTestHistoryResponse)
+	err := c.client.Call(ctx, "weetbix.v1.TestHistory", "Query", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *testHistoryPRPCClient) QueryStats(ctx context.Context, in *QueryTestHistoryStatsRequest, opts ...grpc.CallOption) (*QueryTestHistoryStatsResponse, error) {
+	out := new(QueryTestHistoryStatsResponse)
+	err := c.client.Call(ctx, "weetbix.v1.TestHistory", "QueryStats", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *testHistoryPRPCClient) QueryVariants(ctx context.Context, in *QueryVariantsRequest, opts ...grpc.CallOption) (*QueryVariantsResponse, error) {
+	out := new(QueryVariantsResponse)
+	err := c.client.Call(ctx, "weetbix.v1.TestHistory", "QueryVariants", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *testHistoryPRPCClient) QueryTests(ctx context.Context, in *QueryTestsRequest, opts ...grpc.CallOption) (*QueryTestsResponse, error) {
+	out := new(QueryTestsResponse)
+	err := c.client.Call(ctx, "weetbix.v1.TestHistory", "QueryTests", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type testHistoryClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewTestHistoryClient(cc grpc.ClientConnInterface) TestHistoryClient {
+	return &testHistoryClient{cc}
+}
+
+func (c *testHistoryClient) Query(ctx context.Context, in *QueryTestHistoryRequest, opts ...grpc.CallOption) (*QueryTestHistoryResponse, error) {
+	out := new(QueryTestHistoryResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.TestHistory/Query", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *testHistoryClient) QueryStats(ctx context.Context, in *QueryTestHistoryStatsRequest, opts ...grpc.CallOption) (*QueryTestHistoryStatsResponse, error) {
+	out := new(QueryTestHistoryStatsResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.TestHistory/QueryStats", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *testHistoryClient) QueryVariants(ctx context.Context, in *QueryVariantsRequest, opts ...grpc.CallOption) (*QueryVariantsResponse, error) {
+	out := new(QueryVariantsResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.TestHistory/QueryVariants", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+func (c *testHistoryClient) QueryTests(ctx context.Context, in *QueryTestsRequest, opts ...grpc.CallOption) (*QueryTestsResponse, error) {
+	out := new(QueryTestsResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.TestHistory/QueryTests", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// TestHistoryServer is the server API for TestHistory service.
+type TestHistoryServer interface {
+	// Retrieves test verdicts for a given test ID in a given project and in a
+	// given range of time.
+	// Accepts a test variant predicate to filter the verdicts.
+	Query(context.Context, *QueryTestHistoryRequest) (*QueryTestHistoryResponse, error)
+	// Retrieves a summary of test verdicts for a given test ID in a given project
+	// and in a given range of times.
+	// Accepts a test variant predicate to filter the verdicts.
+	QueryStats(context.Context, *QueryTestHistoryStatsRequest) (*QueryTestHistoryStatsResponse, error)
+	// Retrieves variants for a given test ID in a given project that were
+	// recorded in the past 90 days.
+	QueryVariants(context.Context, *QueryVariantsRequest) (*QueryVariantsResponse, error)
+	// Finds test IDs that contain the given substring in a given project that
+	// were recorded in the past 90 days.
+	QueryTests(context.Context, *QueryTestsRequest) (*QueryTestsResponse, error)
+}
+
+// UnimplementedTestHistoryServer can be embedded to have forward compatible implementations.
+type UnimplementedTestHistoryServer struct {
+}
+
+func (*UnimplementedTestHistoryServer) Query(context.Context, *QueryTestHistoryRequest) (*QueryTestHistoryResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method Query not implemented")
+}
+func (*UnimplementedTestHistoryServer) QueryStats(context.Context, *QueryTestHistoryStatsRequest) (*QueryTestHistoryStatsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryStats not implemented")
+}
+func (*UnimplementedTestHistoryServer) QueryVariants(context.Context, *QueryVariantsRequest) (*QueryVariantsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryVariants not implemented")
+}
+func (*UnimplementedTestHistoryServer) QueryTests(context.Context, *QueryTestsRequest) (*QueryTestsResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryTests not implemented")
+}
+
+func RegisterTestHistoryServer(s prpc.Registrar, srv TestHistoryServer) {
+	s.RegisterService(&_TestHistory_serviceDesc, srv)
+}
+
+func _TestHistory_Query_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryTestHistoryRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(TestHistoryServer).Query(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.TestHistory/Query",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(TestHistoryServer).Query(ctx, req.(*QueryTestHistoryRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _TestHistory_QueryStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryTestHistoryStatsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(TestHistoryServer).QueryStats(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.TestHistory/QueryStats",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(TestHistoryServer).QueryStats(ctx, req.(*QueryTestHistoryStatsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _TestHistory_QueryVariants_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryVariantsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(TestHistoryServer).QueryVariants(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.TestHistory/QueryVariants",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(TestHistoryServer).QueryVariants(ctx, req.(*QueryVariantsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+func _TestHistory_QueryTests_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryTestsRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(TestHistoryServer).QueryTests(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.TestHistory/QueryTests",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(TestHistoryServer).QueryTests(ctx, req.(*QueryTestsRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _TestHistory_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.v1.TestHistory",
+	HandlerType: (*TestHistoryServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "Query",
+			Handler:    _TestHistory_Query_Handler,
+		},
+		{
+			MethodName: "QueryStats",
+			Handler:    _TestHistory_QueryStats_Handler,
+		},
+		{
+			MethodName: "QueryVariants",
+			Handler:    _TestHistory_QueryVariants_Handler,
+		},
+		{
+			MethodName: "QueryTests",
+			Handler:    _TestHistory_QueryTests_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/proto/v1/test_history.proto",
+}
diff --git a/analysis/proto/v1/test_history.proto b/analysis/proto/v1/test_history.proto
new file mode 100644
index 0000000..c8e3856
--- /dev/null
+++ b/analysis/proto/v1/test_history.proto
@@ -0,0 +1,263 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+import "google/api/field_behavior.proto";
+import "google/protobuf/duration.proto";
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+import "go.chromium.org/luci/analysis/proto/v1/predicate.proto";
+import "go.chromium.org/luci/analysis/proto/v1/test_verdict.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+// Provide methods to read test histories.
+service TestHistory {
+  // Retrieves test verdicts for a given test ID in a given project and in a
+  // given range of time.
+  // Accepts a test variant predicate to filter the verdicts.
+  rpc Query(QueryTestHistoryRequest) returns (
+    QueryTestHistoryResponse) {};
+
+  // Retrieves a summary of test verdicts for a given test ID in a given project
+  // and in a given range of times.
+  // Accepts a test variant predicate to filter the verdicts.
+  rpc QueryStats(QueryTestHistoryStatsRequest) returns (
+    QueryTestHistoryStatsResponse) {};
+
+  // Retrieves variants for a given test ID in a given project that were
+  // recorded in the past 90 days.
+  rpc QueryVariants(QueryVariantsRequest) returns (QueryVariantsResponse) {};
+
+  // Finds test IDs that contain the given substring in a given project that
+  // were recorded in the past 90 days.
+  rpc QueryTests(QueryTestsRequest) returns (QueryTestsResponse) {};
+}
+
+// A request message for `TestHistory.Query` RPC.
+message QueryTestHistoryRequest {
+  // Required. The LUCI Project of the test results.
+  // I.e. For a result to be part of the history, it needs to be contained
+  // transitively by an invocation in this project.
+  string project = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The test ID to query the history from.
+  string test_id = 2
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Required. A test verdict in the response must satisfy this predicate.
+  weetbix.v1.TestVerdictPredicate predicate = 3
+    [(google.api.field_behavior) = REQUIRED];
+
+  // The maximum number of entries to return.
+  //
+  // The service may return fewer than this value.
+  // If unspecified, at most 100 variants will be returned.
+  // The maximum value is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 4;
+
+  // A page token, received from a previous call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to the next call MUST
+  // match the call that provided the page token.
+  string page_token = 5;
+}
+
+// A response message for `TestHistory.Query` RPC.
+message QueryTestHistoryResponse {
+  // The list of test verdicts.
+  // Test verdicts will be ordered by `partition_time` DESC, `variant_hash` ASC,
+  // `invocation_id` ASC.
+  repeated weetbix.v1.TestVerdict verdicts = 1;
+
+  // This field will be set if there are more results to return.
+  // To get the next page of data, send the same request again, but include this
+  // token.
+  string next_page_token = 2;
+}
+
+// A request message for `TestHistory.QueryStats` RPC.
+message QueryTestHistoryStatsRequest {
+  // Required. The LUCI Project of the test results.
+  // I.e. For a result to be part of the history, it needs to be contained
+  // transitively by an invocation in this project.
+  string project = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The test ID to query the history from.
+  string test_id = 2
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Required. A test verdict in the response must satisfy this predicate.
+  weetbix.v1.TestVerdictPredicate predicate = 3
+    [(google.api.field_behavior) = REQUIRED];
+
+  // The maximum number of entries to return.
+  //
+  // The service may return fewer than this value.
+  // If unspecified, at most 100 variants will be returned.
+  // The maximum value is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 4;
+
+  // A page token, received from a previous call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to the next call
+  // MUST match the call that provided the page token.
+  string page_token = 5;
+}
+
+// A response message for `TestHistory.QueryStats` RPC.
+message QueryTestHistoryStatsResponse {
+  message Group {
+    // The start time of this group.
+    // Test verdicts that are paritioned in the 24 hours following this
+    // timestamp are captured in this group.
+    google.protobuf.Timestamp partition_time = 1;
+
+    // The hash of the variant.
+    string variant_hash = 2;
+
+    // The number of unexpected test verdicts in the group.
+    int32 unexpected_count = 3;
+
+    // The number of unexpectedly skipped test verdicts in the group.
+    int32 unexpectedly_skipped_count = 4;
+
+    // The number of flaky test verdicts in the group.
+    int32 flaky_count = 5;
+
+    // The number of exonerated test verdicts in the group.
+    int32 exonerated_count = 6;
+
+    // The number of expected test verdicts in the group.
+    int32 expected_count = 7;
+
+    // The average duration of passing test results in the group.
+    google.protobuf.Duration passed_avg_duration = 8;
+  }
+
+  // The list of test verdict groups. Test verdicts will be grouped and ordered
+  // by `partition_date` DESC, `variant_hash` ASC.
+  repeated Group groups = 1;
+
+  // This field will be set if there are more results to return.
+  // To get the next page of data, send the same request again, but include this
+  // token.
+  string next_page_token = 2;
+}
+
+// A request message for the `QueryVariants` RPC.
+message QueryVariantsRequest {
+  // Required. The LUCI project to query the variants from.
+  string project = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Required. The test ID to query the variants from.
+  string test_id = 2
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Optional. The project-scoped realm to query the variants from.
+  // This is the realm without the "<project>:" prefix.
+  //
+  // When specified, only the test variants found in the matching realm will be
+  // returned.
+  string sub_realm = 3;
+
+  // Optional. When specified, only variant matches this predicate will be
+  // returned.
+  VariantPredicate variant_predicate = 6;
+
+  // The maximum number of variants to return.
+  //
+  // The service may return fewer than this value.
+  // If unspecified, at most 100 variants will be returned.
+  // The maximum value is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 4;
+
+  // A page token, received from a previous `QueryVariants` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `QueryVariants` MUST
+  // match the call that provided the page token.
+  string page_token = 5;
+}
+
+// A response message for the `QueryVariants` RPC.
+message QueryVariantsResponse {
+  // Contains the variant definition and its hash.
+  message VariantInfo {
+    // The hash of the variant.
+    string variant_hash = 1;
+
+    // The definition of the variant.
+    weetbix.v1.Variant variant = 2;
+  }
+
+  // A list of variants. Ordered by variant hash.
+  repeated VariantInfo variants = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there were no subsequent pages at the time of
+  // request.
+  string next_page_token = 2;
+}
+
+// A request message for the `QueryTests` RPC.
+message QueryTestsRequest {
+  // Required. The LUCI project to query the tests from.
+  string project = 1
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Required. Only tests that contain the substring will be returned.
+  string test_id_substring = 2
+    [(google.api.field_behavior) = REQUIRED];
+
+  // Optional. The project-scoped realm to query the variants from.
+  // This is the realm without the "<project>:" prefix.
+  //
+  // When specified, only the tests found in the matching realm will be
+  // returned.
+  string sub_realm = 3;
+
+  // The maximum number of test IDs to return.
+  //
+  // The service may return fewer than this value.
+  // If unspecified, at most 100 test IDs will be returned.
+  // The maximum value is 1000; values above 1000 will be coerced to 1000.
+  int32 page_size = 4;
+
+  // A page token, received from a previous `QueryTests` call.
+  // Provide this to retrieve the subsequent page.
+  //
+  // When paginating, all other parameters provided to `QueryTests` MUST
+  // match the call that provided the page token.
+  string page_token = 5;
+}
+
+// A response message for the `QueryTests` RPC.
+message QueryTestsResponse {
+  // A list of test Ids. Ordered alphabetically.
+  repeated string test_ids = 1;
+
+  // A token, which can be sent as `page_token` to retrieve the next page.
+  // If this field is omitted, there were no subsequent pages at the time of
+  // request.
+  string next_page_token = 2;
+}
diff --git a/analysis/proto/v1/test_variants.pb.go b/analysis/proto/v1/test_variants.pb.go
new file mode 100644
index 0000000..046e5c4
--- /dev/null
+++ b/analysis/proto/v1/test_variants.pb.go
@@ -0,0 +1,1114 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/test_variants.proto
+
+package weetbixpb
+
+import prpc "go.chromium.org/luci/grpc/prpc"
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type QueryTestVariantFailureRateRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The LUCI Project for which test variants should be looked up.
+	Project string `protobuf:"bytes,1,opt,name=project,proto3" json:"project,omitempty"`
+	// The list of test variants to retrieve results for.
+	// At most 100 test variants may be specified in one request.
+	// It is an error to request the same test variant twice.
+	TestVariants []*TestVariantIdentifier `protobuf:"bytes,2,rep,name=test_variants,json=testVariants,proto3" json:"test_variants,omitempty"`
+}
+
+func (x *QueryTestVariantFailureRateRequest) Reset() {
+	*x = QueryTestVariantFailureRateRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestVariantFailureRateRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestVariantFailureRateRequest) ProtoMessage() {}
+
+func (x *QueryTestVariantFailureRateRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestVariantFailureRateRequest.ProtoReflect.Descriptor instead.
+func (*QueryTestVariantFailureRateRequest) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *QueryTestVariantFailureRateRequest) GetProject() string {
+	if x != nil {
+		return x.Project
+	}
+	return ""
+}
+
+func (x *QueryTestVariantFailureRateRequest) GetTestVariants() []*TestVariantIdentifier {
+	if x != nil {
+		return x.TestVariants
+	}
+	return nil
+}
+
+// The identity of a test variant.
+type TestVariantIdentifier struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A unique identifier of the test in a LUCI project.
+	TestId string `protobuf:"bytes,1,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Description of one specific way of running the test,
+	// e.g. a specific bucket, builder and a test suite.
+	Variant *Variant `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"`
+	// The variant hash. Alternative to specifying the variant.
+	// Prefer to specify the full variant (if available), as the
+	// variant hashing implementation is an implementation detail
+	// and may change.
+	VariantHash string `protobuf:"bytes,3,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+}
+
+func (x *TestVariantIdentifier) Reset() {
+	*x = TestVariantIdentifier{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantIdentifier) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantIdentifier) ProtoMessage() {}
+
+func (x *TestVariantIdentifier) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantIdentifier.ProtoReflect.Descriptor instead.
+func (*TestVariantIdentifier) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *TestVariantIdentifier) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *TestVariantIdentifier) GetVariant() *Variant {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *TestVariantIdentifier) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+type QueryTestVariantFailureRateResponse struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The time buckets used for time interval data.
+	//
+	// Currently each interval represents 24 weekday hours, including the
+	// weekend contained in that range (if any). This is to compensate
+	// for the typically reduced testing that is seen over weekends.
+	// So interval with age=1 is the last 24 hours of weekday data
+	// before the time the query is made, age=2 is the 24 hours of
+	// weekday data before that, and so on.
+	// In total, there will be 5 intervals, numbered 1 to 5.
+	//
+	// 24 hours of weekday data before X is defined to be
+	// the smallest period ending at X which includes exactly 24
+	// hours of a weekday in UTC. Therefore:
+	// If X is on a weekend (in UTC), the returned data will
+	// cover all of the weekend up to X and all of previous Friday (in UTC).
+	// If X is on a Monday (in UTC), the returned data will cover all
+	// of the weekend, up to a time on Friday that corresponds to
+	// X's time on Monday (e.g. if X is Monday at 8am, the period goes
+	// back to Friday at 8am).
+	// Otherwise, X is on a Tuesday to Friday (in UTC), the period
+	// will cover the last 24 hours.
+	Intervals []*QueryTestVariantFailureRateResponse_Interval `protobuf:"bytes,1,rep,name=intervals,proto3" json:"intervals,omitempty"`
+	// The test variant failure rate analysis requested.
+	// Test variants are returned in the order they were requested.
+	TestVariants []*TestVariantFailureRateAnalysis `protobuf:"bytes,2,rep,name=test_variants,json=testVariants,proto3" json:"test_variants,omitempty"`
+}
+
+func (x *QueryTestVariantFailureRateResponse) Reset() {
+	*x = QueryTestVariantFailureRateResponse{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[2]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestVariantFailureRateResponse) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestVariantFailureRateResponse) ProtoMessage() {}
+
+func (x *QueryTestVariantFailureRateResponse) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[2]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestVariantFailureRateResponse.ProtoReflect.Descriptor instead.
+func (*QueryTestVariantFailureRateResponse) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *QueryTestVariantFailureRateResponse) GetIntervals() []*QueryTestVariantFailureRateResponse_Interval {
+	if x != nil {
+		return x.Intervals
+	}
+	return nil
+}
+
+func (x *QueryTestVariantFailureRateResponse) GetTestVariants() []*TestVariantFailureRateAnalysis {
+	if x != nil {
+		return x.TestVariants
+	}
+	return nil
+}
+
+// Signals relevant to determining whether a test variant should be
+// exonerated in presubmit.
+type TestVariantFailureRateAnalysis struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// A unique identifier of the test in a LUCI project.
+	TestId string `protobuf:"bytes,1,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// Description of one specific way of running the test,
+	// e.g. a specific bucket, builder and a test suite.
+	// Only populated if populated on the request.
+	Variant *Variant `protobuf:"bytes,2,opt,name=variant,proto3" json:"variant,omitempty"`
+	// The variant hash.
+	// Only populated if populated on the request.
+	VariantHash string `protobuf:"bytes,3,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// Statistics broken down by time interval. Intervals will be ordered
+	// by recency, starting at the most recent interval (age = 1).
+	//
+	// The following filtering applies to verdicts used in time interval data:
+	// - Verdicts are filtered to at most one per unique CL under test,
+	//   with verdicts for multi-CL tryjob runs excluded.
+	IntervalStats []*TestVariantFailureRateAnalysis_IntervalStats `protobuf:"bytes,4,rep,name=interval_stats,json=intervalStats,proto3" json:"interval_stats,omitempty"`
+	// Examples of verdicts which had both expected and unexpected runs.
+	//
+	// Ordered by recency, starting at the most recent example at offset 0.
+	//
+	// Limited to at most 10. Further limited to only verdicts produced
+	// since 5 weekdays ago (this corresponds to the exact same time range
+	// as for which interval data is provided).
+	RunFlakyVerdictExamples []*TestVariantFailureRateAnalysis_VerdictExample `protobuf:"bytes,5,rep,name=run_flaky_verdict_examples,json=runFlakyVerdictExamples,proto3" json:"run_flaky_verdict_examples,omitempty"`
+	// The most recent verdicts for the test variant.
+	//
+	// The following filtering applies to verdicts used in this field:
+	// - Verdicts are filtered to at most one per unique CL under test,
+	//   with verdicts for multi-CL tryjob runs excluded.
+	// - Verdicts for CLs authored by automation are excluded, to avoid a
+	//   single repeatedly failing automatic uprev process populating
+	//   this list with 10 failures.
+	// Ordered by recency, starting at the most recent verdict at offset 0.
+	//
+	// Limited to at most 10. Further limited to only verdicts produced
+	// since 5 weekdays ago (this corresponds to the exact same time range
+	// as for which interval data is provided).
+	RecentVerdicts []*TestVariantFailureRateAnalysis_RecentVerdict `protobuf:"bytes,6,rep,name=recent_verdicts,json=recentVerdicts,proto3" json:"recent_verdicts,omitempty"`
+}
+
+func (x *TestVariantFailureRateAnalysis) Reset() {
+	*x = TestVariantFailureRateAnalysis{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[3]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantFailureRateAnalysis) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantFailureRateAnalysis) ProtoMessage() {}
+
+func (x *TestVariantFailureRateAnalysis) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[3]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantFailureRateAnalysis.ProtoReflect.Descriptor instead.
+func (*TestVariantFailureRateAnalysis) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *TestVariantFailureRateAnalysis) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *TestVariantFailureRateAnalysis) GetVariant() *Variant {
+	if x != nil {
+		return x.Variant
+	}
+	return nil
+}
+
+func (x *TestVariantFailureRateAnalysis) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *TestVariantFailureRateAnalysis) GetIntervalStats() []*TestVariantFailureRateAnalysis_IntervalStats {
+	if x != nil {
+		return x.IntervalStats
+	}
+	return nil
+}
+
+func (x *TestVariantFailureRateAnalysis) GetRunFlakyVerdictExamples() []*TestVariantFailureRateAnalysis_VerdictExample {
+	if x != nil {
+		return x.RunFlakyVerdictExamples
+	}
+	return nil
+}
+
+func (x *TestVariantFailureRateAnalysis) GetRecentVerdicts() []*TestVariantFailureRateAnalysis_RecentVerdict {
+	if x != nil {
+		return x.RecentVerdicts
+	}
+	return nil
+}
+
+// Interval defines the time buckets used for time interval
+// data.
+type QueryTestVariantFailureRateResponse_Interval struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The interval being defined. age=1 is the most recent
+	// interval, age=2 is the interval immediately before that,
+	// and so on.
+	IntervalAge int32 `protobuf:"varint,1,opt,name=interval_age,json=intervalAge,proto3" json:"interval_age,omitempty"`
+	// The start time of the interval (inclusive).
+	StartTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=start_time,json=startTime,proto3" json:"start_time,omitempty"`
+	// The end time of the interval (exclusive).
+	EndTime *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=end_time,json=endTime,proto3" json:"end_time,omitempty"`
+}
+
+func (x *QueryTestVariantFailureRateResponse_Interval) Reset() {
+	*x = QueryTestVariantFailureRateResponse_Interval{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[4]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *QueryTestVariantFailureRateResponse_Interval) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*QueryTestVariantFailureRateResponse_Interval) ProtoMessage() {}
+
+func (x *QueryTestVariantFailureRateResponse_Interval) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[4]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use QueryTestVariantFailureRateResponse_Interval.ProtoReflect.Descriptor instead.
+func (*QueryTestVariantFailureRateResponse_Interval) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{2, 0}
+}
+
+func (x *QueryTestVariantFailureRateResponse_Interval) GetIntervalAge() int32 {
+	if x != nil {
+		return x.IntervalAge
+	}
+	return 0
+}
+
+func (x *QueryTestVariantFailureRateResponse_Interval) GetStartTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.StartTime
+	}
+	return nil
+}
+
+func (x *QueryTestVariantFailureRateResponse_Interval) GetEndTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.EndTime
+	}
+	return nil
+}
+
+type TestVariantFailureRateAnalysis_IntervalStats struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The age of the interval. 1 is the most recent interval,
+	// 2 is the interval immediately before that, and so on.
+	// Cross reference with the intervals field on the
+	// QueryTestVariantFailureRateResponse response to
+	// identify the exact time interval this represents.
+	IntervalAge int32 `protobuf:"varint,1,opt,name=interval_age,json=intervalAge,proto3" json:"interval_age,omitempty"`
+	// The number of verdicts which had only expected runs.
+	// An expected run is a run (e.g. swarming task) which has at least
+	// one expected result, excluding skipped results.
+	TotalRunExpectedVerdicts int32 `protobuf:"varint,2,opt,name=total_run_expected_verdicts,json=totalRunExpectedVerdicts,proto3" json:"total_run_expected_verdicts,omitempty"`
+	// The number of verdicts which had both expected and
+	// unexpected runs.
+	// An expected run is a run (e.g. swarming task) which has at least
+	// one expected result, excluding skips.
+	// An unexpected run is a run which had only unexpected
+	// results (and at least one unexpected result), excluding skips.
+	TotalRunFlakyVerdicts int32 `protobuf:"varint,3,opt,name=total_run_flaky_verdicts,json=totalRunFlakyVerdicts,proto3" json:"total_run_flaky_verdicts,omitempty"`
+	// The number of verdicts which had only unexpected runs.
+	// An unexpected run is a run (e.g. swarming task) which had only
+	// unexpected results (and at least one unexpected result),
+	// excluding skips.
+	TotalRunUnexpectedVerdicts int32 `protobuf:"varint,4,opt,name=total_run_unexpected_verdicts,json=totalRunUnexpectedVerdicts,proto3" json:"total_run_unexpected_verdicts,omitempty"`
+}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) Reset() {
+	*x = TestVariantFailureRateAnalysis_IntervalStats{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[5]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantFailureRateAnalysis_IntervalStats) ProtoMessage() {}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[5]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantFailureRateAnalysis_IntervalStats.ProtoReflect.Descriptor instead.
+func (*TestVariantFailureRateAnalysis_IntervalStats) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{3, 0}
+}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) GetIntervalAge() int32 {
+	if x != nil {
+		return x.IntervalAge
+	}
+	return 0
+}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) GetTotalRunExpectedVerdicts() int32 {
+	if x != nil {
+		return x.TotalRunExpectedVerdicts
+	}
+	return 0
+}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) GetTotalRunFlakyVerdicts() int32 {
+	if x != nil {
+		return x.TotalRunFlakyVerdicts
+	}
+	return 0
+}
+
+func (x *TestVariantFailureRateAnalysis_IntervalStats) GetTotalRunUnexpectedVerdicts() int32 {
+	if x != nil {
+		return x.TotalRunUnexpectedVerdicts
+	}
+	return 0
+}
+
+// VerdictExample describes a verdict that is part of a statistic.
+type TestVariantFailureRateAnalysis_VerdictExample struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The partition time of the verdict. This the time associated with the
+	// test result for test history purposes, usually the build or presubmit
+	// run start time.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// The identity of the ingested invocation.
+	IngestedInvocationId string `protobuf:"bytes,2,opt,name=ingested_invocation_id,json=ingestedInvocationId,proto3" json:"ingested_invocation_id,omitempty"`
+	// The changelist(s) tested, if any.
+	Changelists []*Changelist `protobuf:"bytes,3,rep,name=changelists,proto3" json:"changelists,omitempty"`
+}
+
+func (x *TestVariantFailureRateAnalysis_VerdictExample) Reset() {
+	*x = TestVariantFailureRateAnalysis_VerdictExample{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[6]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantFailureRateAnalysis_VerdictExample) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantFailureRateAnalysis_VerdictExample) ProtoMessage() {}
+
+func (x *TestVariantFailureRateAnalysis_VerdictExample) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[6]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantFailureRateAnalysis_VerdictExample.ProtoReflect.Descriptor instead.
+func (*TestVariantFailureRateAnalysis_VerdictExample) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{3, 1}
+}
+
+func (x *TestVariantFailureRateAnalysis_VerdictExample) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *TestVariantFailureRateAnalysis_VerdictExample) GetIngestedInvocationId() string {
+	if x != nil {
+		return x.IngestedInvocationId
+	}
+	return ""
+}
+
+func (x *TestVariantFailureRateAnalysis_VerdictExample) GetChangelists() []*Changelist {
+	if x != nil {
+		return x.Changelists
+	}
+	return nil
+}
+
+type TestVariantFailureRateAnalysis_RecentVerdict struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// The partition time of the verdict. This the time associated with the
+	// test result for test history purposes, usually the build or presubmit
+	// run start time.
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,1,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// The identity of the ingested invocation.
+	IngestedInvocationId string `protobuf:"bytes,2,opt,name=ingested_invocation_id,json=ingestedInvocationId,proto3" json:"ingested_invocation_id,omitempty"`
+	// The changelist(s) tested, if any.
+	Changelists []*Changelist `protobuf:"bytes,3,rep,name=changelists,proto3" json:"changelists,omitempty"`
+	// Whether the verdict had an unexpected run.
+	// An unexpected run is a run (e.g. swarming task) which
+	// had only unexpected results, after excluding skips.
+	//
+	// Example: a verdict includes the result of two
+	// swarming tasks (i.e. two runs), which each contain two
+	// test results.
+	// One of the two test runs has two unexpected failures.
+	// Therefore, the verdict has an unexpected run.
+	HasUnexpectedRuns bool `protobuf:"varint,4,opt,name=has_unexpected_runs,json=hasUnexpectedRuns,proto3" json:"has_unexpected_runs,omitempty"`
+}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) Reset() {
+	*x = TestVariantFailureRateAnalysis_RecentVerdict{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[7]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVariantFailureRateAnalysis_RecentVerdict) ProtoMessage() {}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[7]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVariantFailureRateAnalysis_RecentVerdict.ProtoReflect.Descriptor instead.
+func (*TestVariantFailureRateAnalysis_RecentVerdict) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP(), []int{3, 2}
+}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) GetIngestedInvocationId() string {
+	if x != nil {
+		return x.IngestedInvocationId
+	}
+	return ""
+}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) GetChangelists() []*Changelist {
+	if x != nil {
+		return x.Changelists
+	}
+	return nil
+}
+
+func (x *TestVariantFailureRateAnalysis_RecentVerdict) GetHasUnexpectedRuns() bool {
+	if x != nil {
+		return x.HasUnexpectedRuns
+	}
+	return false
+}
+
+var File_infra_appengine_weetbix_proto_v1_test_variants_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDesc = []byte{
+	0x0a, 0x34, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73,
+	0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e,
+	0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x1a, 0x2d, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e,
+	0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x1a, 0x31, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67,
+	0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x86, 0x01, 0x0a, 0x22, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54,
+	0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72,
+	0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x18, 0x0a, 0x07,
+	0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70,
+	0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x46, 0x0a, 0x0d, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x21, 0x2e,
+	0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x49, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72,
+	0x52, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x22, 0x82,
+	0x01, 0x0a, 0x15, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x49, 0x64,
+	0x65, 0x6e, 0x74, 0x69, 0x66, 0x69, 0x65, 0x72, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74,
+	0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49,
+	0x64, 0x12, 0x2d, 0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x02, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e,
+	0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x12, 0x21, 0x0a, 0x0c, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48,
+	0x61, 0x73, 0x68, 0x22, 0xf0, 0x02, 0x0a, 0x23, 0x51, 0x75, 0x65, 0x72, 0x79, 0x54, 0x65, 0x73,
+	0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52,
+	0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x56, 0x0a, 0x09, 0x69,
+	0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38,
+	0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72,
+	0x79, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c,
+	0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e,
+	0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x52, 0x09, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76,
+	0x61, 0x6c, 0x73, 0x12, 0x4f, 0x0a, 0x0d, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x77, 0x65, 0x65,
+	0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65, 0x41, 0x6e,
+	0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x52, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69,
+	0x61, 0x6e, 0x74, 0x73, 0x1a, 0x9f, 0x01, 0x0a, 0x08, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61,
+	0x6c, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x61, 0x67,
+	0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61,
+	0x6c, 0x41, 0x67, 0x65, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x5f, 0x74, 0x69,
+	0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c,
+	0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73,
+	0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x12,
+	0x35, 0x0a, 0x08, 0x65, 0x6e, 0x64, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28,
+	0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
+	0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x07, 0x65,
+	0x6e, 0x64, 0x54, 0x69, 0x6d, 0x65, 0x22, 0xf2, 0x08, 0x0a, 0x1e, 0x54, 0x65, 0x73, 0x74, 0x56,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74,
+	0x65, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73,
+	0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74,
+	0x49, 0x64, 0x12, 0x2d, 0x0a, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x18, 0x02, 0x20,
+	0x01, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x52, 0x07, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e,
+	0x74, 0x12, 0x21, 0x0a, 0x0c, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73,
+	0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74,
+	0x48, 0x61, 0x73, 0x68, 0x12, 0x5f, 0x0a, 0x0e, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c,
+	0x5f, 0x73, 0x74, 0x61, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x77,
+	0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61,
+	0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65,
+	0x41, 0x6e, 0x61, 0x6c, 0x79, 0x73, 0x69, 0x73, 0x2e, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61,
+	0x6c, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x0d, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c,
+	0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x76, 0x0a, 0x1a, 0x72, 0x75, 0x6e, 0x5f, 0x66, 0x6c, 0x61,
+	0x6b, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x5f, 0x65, 0x78, 0x61, 0x6d, 0x70,
+	0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x39, 0x2e, 0x77, 0x65, 0x65, 0x74,
+	0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61,
+	0x6e, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65, 0x41, 0x6e, 0x61,
+	0x6c, 0x79, 0x73, 0x69, 0x73, 0x2e, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x45, 0x78, 0x61,
+	0x6d, 0x70, 0x6c, 0x65, 0x52, 0x17, 0x72, 0x75, 0x6e, 0x46, 0x6c, 0x61, 0x6b, 0x79, 0x56, 0x65,
+	0x72, 0x64, 0x69, 0x63, 0x74, 0x45, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x61, 0x0a,
+	0x0f, 0x72, 0x65, 0x63, 0x65, 0x6e, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73,
+	0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x38, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46,
+	0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x73,
+	0x69, 0x73, 0x2e, 0x52, 0x65, 0x63, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74,
+	0x52, 0x0e, 0x72, 0x65, 0x63, 0x65, 0x6e, 0x74, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73,
+	0x1a, 0xed, 0x01, 0x0a, 0x0d, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x53, 0x74, 0x61,
+	0x74, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x5f, 0x61,
+	0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0b, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76,
+	0x61, 0x6c, 0x41, 0x67, 0x65, 0x12, 0x3d, 0x0a, 0x1b, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x72,
+	0x75, 0x6e, 0x5f, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x76, 0x65, 0x72, 0x64,
+	0x69, 0x63, 0x74, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x18, 0x74, 0x6f, 0x74, 0x61,
+	0x6c, 0x52, 0x75, 0x6e, 0x45, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x56, 0x65, 0x72, 0x64,
+	0x69, 0x63, 0x74, 0x73, 0x12, 0x37, 0x0a, 0x18, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x72, 0x75,
+	0x6e, 0x5f, 0x66, 0x6c, 0x61, 0x6b, 0x79, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73,
+	0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x15, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x52, 0x75, 0x6e,
+	0x46, 0x6c, 0x61, 0x6b, 0x79, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73, 0x12, 0x41, 0x0a,
+	0x1d, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x72, 0x75, 0x6e, 0x5f, 0x75, 0x6e, 0x65, 0x78, 0x70,
+	0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73, 0x18, 0x04,
+	0x20, 0x01, 0x28, 0x05, 0x52, 0x1a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x52, 0x75, 0x6e, 0x55, 0x6e,
+	0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x73,
+	0x1a, 0xc3, 0x01, 0x0a, 0x0e, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x45, 0x78, 0x61, 0x6d,
+	0x70, 0x6c, 0x65, 0x12, 0x41, 0x0a, 0x0e, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e,
+	0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
+	0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
+	0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69,
+	0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x34, 0x0a, 0x16, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74,
+	0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64,
+	0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x69, 0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64,
+	0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x38, 0x0a, 0x0b,
+	0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
+	0x0b, 0x32, 0x16, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x43,
+	0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67,
+	0x65, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x1a, 0xf2, 0x01, 0x0a, 0x0d, 0x52, 0x65, 0x63, 0x65, 0x6e,
+	0x74, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x12, 0x41, 0x0a, 0x0e, 0x70, 0x61, 0x72, 0x74,
+	0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b,
+	0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x70, 0x61,
+	0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x34, 0x0a, 0x16, 0x69,
+	0x6e, 0x67, 0x65, 0x73, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69,
+	0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x69, 0x6e, 0x67,
+	0x65, 0x73, 0x74, 0x65, 0x64, 0x49, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x49,
+	0x64, 0x12, 0x38, 0x0a, 0x0b, 0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73,
+	0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78,
+	0x2e, 0x76, 0x31, 0x2e, 0x43, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x52, 0x0b,
+	0x63, 0x68, 0x61, 0x6e, 0x67, 0x65, 0x6c, 0x69, 0x73, 0x74, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x68,
+	0x61, 0x73, 0x5f, 0x75, 0x6e, 0x65, 0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x72, 0x75,
+	0x6e, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x68, 0x61, 0x73, 0x55, 0x6e, 0x65,
+	0x78, 0x70, 0x65, 0x63, 0x74, 0x65, 0x64, 0x52, 0x75, 0x6e, 0x73, 0x32, 0x85, 0x01, 0x0a, 0x0c,
+	0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x73, 0x12, 0x75, 0x0a, 0x10,
+	0x51, 0x75, 0x65, 0x72, 0x79, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65,
+	0x12, 0x2e, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75,
+	0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61,
+	0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x2f, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75,
+	0x65, 0x72, 0x79, 0x54, 0x65, 0x73, 0x74, 0x56, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x46, 0x61,
+	0x69, 0x6c, 0x75, 0x72, 0x65, 0x52, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
+	0x65, 0x22, 0x00, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70,
+	0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x70,
+	0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes = make([]protoimpl.MessageInfo, 8)
+var file_infra_appengine_weetbix_proto_v1_test_variants_proto_goTypes = []interface{}{
+	(*QueryTestVariantFailureRateRequest)(nil),            // 0: weetbix.v1.QueryTestVariantFailureRateRequest
+	(*TestVariantIdentifier)(nil),                         // 1: weetbix.v1.TestVariantIdentifier
+	(*QueryTestVariantFailureRateResponse)(nil),           // 2: weetbix.v1.QueryTestVariantFailureRateResponse
+	(*TestVariantFailureRateAnalysis)(nil),                // 3: weetbix.v1.TestVariantFailureRateAnalysis
+	(*QueryTestVariantFailureRateResponse_Interval)(nil),  // 4: weetbix.v1.QueryTestVariantFailureRateResponse.Interval
+	(*TestVariantFailureRateAnalysis_IntervalStats)(nil),  // 5: weetbix.v1.TestVariantFailureRateAnalysis.IntervalStats
+	(*TestVariantFailureRateAnalysis_VerdictExample)(nil), // 6: weetbix.v1.TestVariantFailureRateAnalysis.VerdictExample
+	(*TestVariantFailureRateAnalysis_RecentVerdict)(nil),  // 7: weetbix.v1.TestVariantFailureRateAnalysis.RecentVerdict
+	(*Variant)(nil),               // 8: weetbix.v1.Variant
+	(*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp
+	(*Changelist)(nil),            // 10: weetbix.v1.Changelist
+}
+var file_infra_appengine_weetbix_proto_v1_test_variants_proto_depIdxs = []int32{
+	1,  // 0: weetbix.v1.QueryTestVariantFailureRateRequest.test_variants:type_name -> weetbix.v1.TestVariantIdentifier
+	8,  // 1: weetbix.v1.TestVariantIdentifier.variant:type_name -> weetbix.v1.Variant
+	4,  // 2: weetbix.v1.QueryTestVariantFailureRateResponse.intervals:type_name -> weetbix.v1.QueryTestVariantFailureRateResponse.Interval
+	3,  // 3: weetbix.v1.QueryTestVariantFailureRateResponse.test_variants:type_name -> weetbix.v1.TestVariantFailureRateAnalysis
+	8,  // 4: weetbix.v1.TestVariantFailureRateAnalysis.variant:type_name -> weetbix.v1.Variant
+	5,  // 5: weetbix.v1.TestVariantFailureRateAnalysis.interval_stats:type_name -> weetbix.v1.TestVariantFailureRateAnalysis.IntervalStats
+	6,  // 6: weetbix.v1.TestVariantFailureRateAnalysis.run_flaky_verdict_examples:type_name -> weetbix.v1.TestVariantFailureRateAnalysis.VerdictExample
+	7,  // 7: weetbix.v1.TestVariantFailureRateAnalysis.recent_verdicts:type_name -> weetbix.v1.TestVariantFailureRateAnalysis.RecentVerdict
+	9,  // 8: weetbix.v1.QueryTestVariantFailureRateResponse.Interval.start_time:type_name -> google.protobuf.Timestamp
+	9,  // 9: weetbix.v1.QueryTestVariantFailureRateResponse.Interval.end_time:type_name -> google.protobuf.Timestamp
+	9,  // 10: weetbix.v1.TestVariantFailureRateAnalysis.VerdictExample.partition_time:type_name -> google.protobuf.Timestamp
+	10, // 11: weetbix.v1.TestVariantFailureRateAnalysis.VerdictExample.changelists:type_name -> weetbix.v1.Changelist
+	9,  // 12: weetbix.v1.TestVariantFailureRateAnalysis.RecentVerdict.partition_time:type_name -> google.protobuf.Timestamp
+	10, // 13: weetbix.v1.TestVariantFailureRateAnalysis.RecentVerdict.changelists:type_name -> weetbix.v1.Changelist
+	0,  // 14: weetbix.v1.TestVariants.QueryFailureRate:input_type -> weetbix.v1.QueryTestVariantFailureRateRequest
+	2,  // 15: weetbix.v1.TestVariants.QueryFailureRate:output_type -> weetbix.v1.QueryTestVariantFailureRateResponse
+	15, // [15:16] is the sub-list for method output_type
+	14, // [14:15] is the sub-list for method input_type
+	14, // [14:14] is the sub-list for extension type_name
+	14, // [14:14] is the sub-list for extension extendee
+	0,  // [0:14] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_test_variants_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_test_variants_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_test_variants_proto != nil {
+		return
+	}
+	file_infra_appengine_weetbix_proto_v1_common_proto_init()
+	file_infra_appengine_weetbix_proto_v1_changelist_proto_init()
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestVariantFailureRateRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantIdentifier); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestVariantFailureRateResponse); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantFailureRateAnalysis); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*QueryTestVariantFailureRateResponse_Interval); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[5].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantFailureRateAnalysis_IntervalStats); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantFailureRateAnalysis_VerdictExample); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVariantFailureRateAnalysis_RecentVerdict); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   8,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_test_variants_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_test_variants_proto_depIdxs,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_test_variants_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_test_variants_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_test_variants_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_test_variants_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_test_variants_proto_depIdxs = nil
+}
+
+// Reference imports to suppress errors if they are not otherwise used.
+var _ context.Context
+var _ grpc.ClientConnInterface
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+const _ = grpc.SupportPackageIsVersion6
+
+// TestVariantsClient is the client API for TestVariants service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
+type TestVariantsClient interface {
+	// Queries the failure rate of specified test variants, returning
+	// signals indicating if the test variant is flaky and/or
+	// deterministically failing. Intended for use by recipes to
+	// inform exoneration decisions.
+	//
+	// TODO(crbug.com/1314194): This is an experimental RPC implemented for
+	// Chrome CQ exoneration and is subject to change or removal.
+	//
+	// Changes to this RPC should comply with https://google.aip.dev/231.
+	QueryFailureRate(ctx context.Context, in *QueryTestVariantFailureRateRequest, opts ...grpc.CallOption) (*QueryTestVariantFailureRateResponse, error)
+}
+type testVariantsPRPCClient struct {
+	client *prpc.Client
+}
+
+func NewTestVariantsPRPCClient(client *prpc.Client) TestVariantsClient {
+	return &testVariantsPRPCClient{client}
+}
+
+func (c *testVariantsPRPCClient) QueryFailureRate(ctx context.Context, in *QueryTestVariantFailureRateRequest, opts ...grpc.CallOption) (*QueryTestVariantFailureRateResponse, error) {
+	out := new(QueryTestVariantFailureRateResponse)
+	err := c.client.Call(ctx, "weetbix.v1.TestVariants", "QueryFailureRate", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+type testVariantsClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewTestVariantsClient(cc grpc.ClientConnInterface) TestVariantsClient {
+	return &testVariantsClient{cc}
+}
+
+func (c *testVariantsClient) QueryFailureRate(ctx context.Context, in *QueryTestVariantFailureRateRequest, opts ...grpc.CallOption) (*QueryTestVariantFailureRateResponse, error) {
+	out := new(QueryTestVariantFailureRateResponse)
+	err := c.cc.Invoke(ctx, "/weetbix.v1.TestVariants/QueryFailureRate", in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// TestVariantsServer is the server API for TestVariants service.
+type TestVariantsServer interface {
+	// Queries the failure rate of specified test variants, returning
+	// signals indicating if the test variant is flaky and/or
+	// deterministically failing. Intended for use by recipes to
+	// inform exoneration decisions.
+	//
+	// TODO(crbug.com/1314194): This is an experimental RPC implemented for
+	// Chrome CQ exoneration and is subject to change or removal.
+	//
+	// Changes to this RPC should comply with https://google.aip.dev/231.
+	QueryFailureRate(context.Context, *QueryTestVariantFailureRateRequest) (*QueryTestVariantFailureRateResponse, error)
+}
+
+// UnimplementedTestVariantsServer can be embedded to have forward compatible implementations.
+type UnimplementedTestVariantsServer struct {
+}
+
+func (*UnimplementedTestVariantsServer) QueryFailureRate(context.Context, *QueryTestVariantFailureRateRequest) (*QueryTestVariantFailureRateResponse, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method QueryFailureRate not implemented")
+}
+
+func RegisterTestVariantsServer(s prpc.Registrar, srv TestVariantsServer) {
+	s.RegisterService(&_TestVariants_serviceDesc, srv)
+}
+
+func _TestVariants_QueryFailureRate_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(QueryTestVariantFailureRateRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(TestVariantsServer).QueryFailureRate(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: "/weetbix.v1.TestVariants/QueryFailureRate",
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(TestVariantsServer).QueryFailureRate(ctx, req.(*QueryTestVariantFailureRateRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+var _TestVariants_serviceDesc = grpc.ServiceDesc{
+	ServiceName: "weetbix.v1.TestVariants",
+	HandlerType: (*TestVariantsServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "QueryFailureRate",
+			Handler:    _TestVariants_QueryFailureRate_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "go.chromium.org/luci/analysis/proto/v1/test_variants.proto",
+}
diff --git a/analysis/proto/v1/test_variants.proto b/analysis/proto/v1/test_variants.proto
new file mode 100644
index 0000000..7f55f56
--- /dev/null
+++ b/analysis/proto/v1/test_variants.proto
@@ -0,0 +1,223 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+import "google/protobuf/timestamp.proto";
+import "go.chromium.org/luci/analysis/proto/v1/common.proto";
+import "go.chromium.org/luci/analysis/proto/v1/changelist.proto";
+
+// Provides methods to obtain statistics about test variants.
+service TestVariants {
+    // Queries the failure rate of specified test variants, returning
+    // signals indicating if the test variant is flaky and/or
+    // deterministically failing. Intended for use by recipes to
+    // inform exoneration decisions.
+    //
+    // TODO(crbug.com/1314194): This is an experimental RPC implemented for
+    // Chrome CQ exoneration and is subject to change or removal.
+    //
+    // Changes to this RPC should comply with https://google.aip.dev/231.
+    rpc QueryFailureRate(QueryTestVariantFailureRateRequest)
+        returns (QueryTestVariantFailureRateResponse) {};
+}
+
+message QueryTestVariantFailureRateRequest {
+    // The LUCI Project for which test variants should be looked up.
+    string project = 1;
+
+    // The list of test variants to retrieve results for.
+    // At most 100 test variants may be specified in one request.
+    // It is an error to request the same test variant twice.
+    repeated TestVariantIdentifier test_variants = 2;
+}
+
+// The identity of a test variant.
+message TestVariantIdentifier {
+    // A unique identifier of the test in a LUCI project.
+    string test_id = 1;
+
+    // Description of one specific way of running the test,
+    // e.g. a specific bucket, builder and a test suite.
+    Variant variant = 2;
+
+    // The variant hash. Alternative to specifying the variant.
+    // Prefer to specify the full variant (if available), as the
+    // variant hashing implementation is an implementation detail
+    // and may change.
+    string variant_hash = 3;
+}
+
+message QueryTestVariantFailureRateResponse {
+    // Interval defines the time buckets used for time interval
+    // data.
+    message Interval {
+        // The interval being defined. age=1 is the most recent
+        // interval, age=2 is the interval immediately before that,
+        // and so on.
+        int32 interval_age = 1;
+
+        // The start time of the interval (inclusive).
+        google.protobuf.Timestamp start_time = 2;
+
+        // The end time of the interval (exclusive).
+        google.protobuf.Timestamp end_time = 3;
+    }
+
+    // The time buckets used for time interval data.
+    //
+    // Currently each interval represents 24 weekday hours, including the
+    // weekend contained in that range (if any). This is to compensate
+    // for the typically reduced testing that is seen over weekends.
+    // So interval with age=1 is the last 24 hours of weekday data
+    // before the time the query is made, age=2 is the 24 hours of
+    // weekday data before that, and so on.
+    // In total, there will be 5 intervals, numbered 1 to 5.
+    //
+    // 24 hours of weekday data before X is defined to be
+    // the smallest period ending at X which includes exactly 24
+    // hours of a weekday in UTC. Therefore:
+    // If X is on a weekend (in UTC), the returned data will
+    // cover all of the weekend up to X and all of previous Friday (in UTC).
+    // If X is on a Monday (in UTC), the returned data will cover all
+    // of the weekend, up to a time on Friday that corresponds to
+    // X's time on Monday (e.g. if X is Monday at 8am, the period goes
+    // back to Friday at 8am).
+    // Otherwise, X is on a Tuesday to Friday (in UTC), the period
+    // will cover the last 24 hours.
+    repeated Interval intervals = 1;
+
+    // The test variant failure rate analysis requested.
+    // Test variants are returned in the order they were requested.
+    repeated TestVariantFailureRateAnalysis test_variants = 2;
+}
+
+
+// Signals relevant to determining whether a test variant should be
+// exonerated in presubmit.
+message TestVariantFailureRateAnalysis {
+    // A unique identifier of the test in a LUCI project.
+    string test_id = 1;
+
+    // Description of one specific way of running the test,
+    // e.g. a specific bucket, builder and a test suite.
+    // Only populated if populated on the request.
+    Variant variant = 2;
+
+    // The variant hash.
+    // Only populated if populated on the request.
+    string variant_hash = 3;
+
+    message IntervalStats {
+        // The age of the interval. 1 is the most recent interval,
+        // 2 is the interval immediately before that, and so on.
+        // Cross reference with the intervals field on the
+        // QueryTestVariantFailureRateResponse response to
+        // identify the exact time interval this represents.
+        int32 interval_age = 1;
+
+        // The number of verdicts which had only expected runs.
+        // An expected run is a run (e.g. swarming task) which has at least
+        // one expected result, excluding skipped results.
+        int32 total_run_expected_verdicts = 2;
+
+        // The number of verdicts which had both expected and 
+        // unexpected runs.
+        // An expected run is a run (e.g. swarming task) which has at least
+        // one expected result, excluding skips.
+        // An unexpected run is a run which had only unexpected
+        // results (and at least one unexpected result), excluding skips.
+        int32 total_run_flaky_verdicts = 3;
+
+        // The number of verdicts which had only unexpected runs.
+        // An unexpected run is a run (e.g. swarming task) which had only
+        // unexpected results (and at least one unexpected result),
+        // excluding skips.
+        int32 total_run_unexpected_verdicts = 4;
+    }
+
+    // Statistics broken down by time interval. Intervals will be ordered
+    // by recency, starting at the most recent interval (age = 1).
+    //
+    // The following filtering applies to verdicts used in time interval data:
+    // - Verdicts are filtered to at most one per unique CL under test,
+    //   with verdicts for multi-CL tryjob runs excluded.
+    repeated IntervalStats interval_stats = 4;
+
+    // VerdictExample describes a verdict that is part of a statistic.
+    message VerdictExample {
+        // The partition time of the verdict. This the time associated with the
+        // test result for test history purposes, usually the build or presubmit
+        // run start time.
+        google.protobuf.Timestamp partition_time = 1;
+
+        // The identity of the ingested invocation.
+        string ingested_invocation_id = 2;
+
+        // The changelist(s) tested, if any.
+        repeated Changelist changelists = 3;
+    }
+
+    // Examples of verdicts which had both expected and unexpected runs.
+    //
+    // Ordered by recency, starting at the most recent example at offset 0.
+    //
+    // Limited to at most 10. Further limited to only verdicts produced
+    // since 5 weekdays ago (this corresponds to the exact same time range
+    // as for which interval data is provided).
+    repeated VerdictExample run_flaky_verdict_examples = 5;
+
+    message RecentVerdict {
+        // The partition time of the verdict. This the time associated with the
+        // test result for test history purposes, usually the build or presubmit
+        // run start time.
+        google.protobuf.Timestamp partition_time = 1;
+
+        // The identity of the ingested invocation.
+        string ingested_invocation_id = 2;
+
+        // The changelist(s) tested, if any.
+        repeated Changelist changelists = 3;
+
+        // Whether the verdict had an unexpected run.
+        // An unexpected run is a run (e.g. swarming task) which
+        // had only unexpected results, after excluding skips.
+        //
+        // Example: a verdict includes the result of two
+        // swarming tasks (i.e. two runs), which each contain two
+        // test results.
+        // One of the two test runs has two unexpected failures.
+        // Therefore, the verdict has an unexpected run.
+        bool has_unexpected_runs = 4;
+    }
+
+    // The most recent verdicts for the test variant.
+    //
+    // The following filtering applies to verdicts used in this field:
+    // - Verdicts are filtered to at most one per unique CL under test,
+    //   with verdicts for multi-CL tryjob runs excluded.
+    // - Verdicts for CLs authored by automation are excluded, to avoid a
+    //   single repeatedly failing automatic uprev process populating
+    //   this list with 10 failures.
+    // Ordered by recency, starting at the most recent verdict at offset 0.
+    //
+    // Limited to at most 10. Further limited to only verdicts produced
+    // since 5 weekdays ago (this corresponds to the exact same time range
+    // as for which interval data is provided).
+    repeated RecentVerdict recent_verdicts = 6;
+}
diff --git a/analysis/proto/v1/test_verdict.pb.go b/analysis/proto/v1/test_verdict.pb.go
new file mode 100644
index 0000000..5bc6539
--- /dev/null
+++ b/analysis/proto/v1/test_verdict.pb.go
@@ -0,0 +1,406 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.28.0
+// 	protoc        v3.17.3
+// source: go.chromium.org/luci/analysis/proto/v1/test_verdict.proto
+
+package weetbixpb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	durationpb "google.golang.org/protobuf/types/known/durationpb"
+	timestamppb "google.golang.org/protobuf/types/known/timestamppb"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Status of a test result.
+// It is a mirror of luci.resultdb.v1.TestStatus, but the right to evolve
+// it independently is reserved.
+type TestResultStatus int32
+
+const (
+	// Status was not specified.
+	// Not to be used in actual test results; serves as a default value for an
+	// unset field.
+	TestResultStatus_TEST_RESULT_STATUS_UNSPECIFIED TestResultStatus = 0
+	// The test case has passed.
+	TestResultStatus_PASS TestResultStatus = 1
+	// The test case has failed.
+	// Suggests that the code under test is incorrect, but it is also possible
+	// that the test is incorrect or it is a flake.
+	TestResultStatus_FAIL TestResultStatus = 2
+	// The test case has crashed during execution.
+	// The outcome is inconclusive: the code under test might or might not be
+	// correct, but the test+code is incorrect.
+	TestResultStatus_CRASH TestResultStatus = 3
+	// The test case has started, but was aborted before finishing.
+	// A common reason: timeout.
+	TestResultStatus_ABORT TestResultStatus = 4
+	// The test case did not execute.
+	// Examples:
+	// - The execution of the collection of test cases, such as a test
+	//   binary, was aborted prematurely and execution of some test cases was
+	//   skipped.
+	// - The test harness configuration specified that the test case MUST be
+	//   skipped.
+	TestResultStatus_SKIP TestResultStatus = 5
+)
+
+// Enum value maps for TestResultStatus.
+var (
+	TestResultStatus_name = map[int32]string{
+		0: "TEST_RESULT_STATUS_UNSPECIFIED",
+		1: "PASS",
+		2: "FAIL",
+		3: "CRASH",
+		4: "ABORT",
+		5: "SKIP",
+	}
+	TestResultStatus_value = map[string]int32{
+		"TEST_RESULT_STATUS_UNSPECIFIED": 0,
+		"PASS":                           1,
+		"FAIL":                           2,
+		"CRASH":                          3,
+		"ABORT":                          4,
+		"SKIP":                           5,
+	}
+)
+
+func (x TestResultStatus) Enum() *TestResultStatus {
+	p := new(TestResultStatus)
+	*p = x
+	return p
+}
+
+func (x TestResultStatus) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (TestResultStatus) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_test_verdict_proto_enumTypes[0].Descriptor()
+}
+
+func (TestResultStatus) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_test_verdict_proto_enumTypes[0]
+}
+
+func (x TestResultStatus) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use TestResultStatus.Descriptor instead.
+func (TestResultStatus) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescGZIP(), []int{0}
+}
+
+// Status of a test verdict.
+// It is a mirror of luci.resultdb.v1.TestVariantStatus.
+type TestVerdictStatus int32
+
+const (
+	// a test verdict must not have this status.
+	// This is only used when filtering verdicts.
+	TestVerdictStatus_TEST_VERDICT_STATUS_UNSPECIFIED TestVerdictStatus = 0
+	// The test verdict has no exonerations, and all results are unexpected.
+	TestVerdictStatus_UNEXPECTED TestVerdictStatus = 10
+	// The test verdict has no exonerations, and all results are unexpectedly skipped.
+	TestVerdictStatus_UNEXPECTEDLY_SKIPPED TestVerdictStatus = 20
+	// The test verdict has no exonerations, and has both expected and unexpected
+	// results.
+	TestVerdictStatus_FLAKY TestVerdictStatus = 30
+	// The test verdict has one or more test exonerations.
+	TestVerdictStatus_EXONERATED TestVerdictStatus = 40
+	// The test verdict has no exonerations, and all results are expected.
+	TestVerdictStatus_EXPECTED TestVerdictStatus = 50
+)
+
+// Enum value maps for TestVerdictStatus.
+var (
+	TestVerdictStatus_name = map[int32]string{
+		0:  "TEST_VERDICT_STATUS_UNSPECIFIED",
+		10: "UNEXPECTED",
+		20: "UNEXPECTEDLY_SKIPPED",
+		30: "FLAKY",
+		40: "EXONERATED",
+		50: "EXPECTED",
+	}
+	TestVerdictStatus_value = map[string]int32{
+		"TEST_VERDICT_STATUS_UNSPECIFIED": 0,
+		"UNEXPECTED":                      10,
+		"UNEXPECTEDLY_SKIPPED":            20,
+		"FLAKY":                           30,
+		"EXONERATED":                      40,
+		"EXPECTED":                        50,
+	}
+)
+
+func (x TestVerdictStatus) Enum() *TestVerdictStatus {
+	p := new(TestVerdictStatus)
+	*p = x
+	return p
+}
+
+func (x TestVerdictStatus) String() string {
+	return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (TestVerdictStatus) Descriptor() protoreflect.EnumDescriptor {
+	return file_infra_appengine_weetbix_proto_v1_test_verdict_proto_enumTypes[1].Descriptor()
+}
+
+func (TestVerdictStatus) Type() protoreflect.EnumType {
+	return &file_infra_appengine_weetbix_proto_v1_test_verdict_proto_enumTypes[1]
+}
+
+func (x TestVerdictStatus) Number() protoreflect.EnumNumber {
+	return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use TestVerdictStatus.Descriptor instead.
+func (TestVerdictStatus) EnumDescriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescGZIP(), []int{1}
+}
+
+type TestVerdict struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	// Unique identifier of the test.
+	// This has the same value as luci.resultdb.v1.TestResult.test_id.
+	TestId string `protobuf:"bytes,1,opt,name=test_id,json=testId,proto3" json:"test_id,omitempty"`
+	// The hash of the variant.
+	VariantHash string `protobuf:"bytes,2,opt,name=variant_hash,json=variantHash,proto3" json:"variant_hash,omitempty"`
+	// The ID of the top-level invocation that the test verdict belongs to when
+	// ingested.
+	InvocationId string `protobuf:"bytes,3,opt,name=invocation_id,json=invocationId,proto3" json:"invocation_id,omitempty"`
+	// The status of the test verdict.
+	Status TestVerdictStatus `protobuf:"varint,4,opt,name=status,proto3,enum=weetbix.v1.TestVerdictStatus" json:"status,omitempty"`
+	// Start time of the presubmit run (for results that are part of a presubmit
+	// run) or start time of the buildbucket build (otherwise).
+	PartitionTime *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=partition_time,json=partitionTime,proto3" json:"partition_time,omitempty"`
+	// The average duration of the PASSED test results included in the test
+	// verdict.
+	PassedAvgDuration *durationpb.Duration `protobuf:"bytes,6,opt,name=passed_avg_duration,json=passedAvgDuration,proto3" json:"passed_avg_duration,omitempty"`
+}
+
+func (x *TestVerdict) Reset() {
+	*x = TestVerdict{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_infra_appengine_weetbix_proto_v1_test_verdict_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *TestVerdict) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TestVerdict) ProtoMessage() {}
+
+func (x *TestVerdict) ProtoReflect() protoreflect.Message {
+	mi := &file_infra_appengine_weetbix_proto_v1_test_verdict_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use TestVerdict.ProtoReflect.Descriptor instead.
+func (*TestVerdict) Descriptor() ([]byte, []int) {
+	return file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *TestVerdict) GetTestId() string {
+	if x != nil {
+		return x.TestId
+	}
+	return ""
+}
+
+func (x *TestVerdict) GetVariantHash() string {
+	if x != nil {
+		return x.VariantHash
+	}
+	return ""
+}
+
+func (x *TestVerdict) GetInvocationId() string {
+	if x != nil {
+		return x.InvocationId
+	}
+	return ""
+}
+
+func (x *TestVerdict) GetStatus() TestVerdictStatus {
+	if x != nil {
+		return x.Status
+	}
+	return TestVerdictStatus_TEST_VERDICT_STATUS_UNSPECIFIED
+}
+
+func (x *TestVerdict) GetPartitionTime() *timestamppb.Timestamp {
+	if x != nil {
+		return x.PartitionTime
+	}
+	return nil
+}
+
+func (x *TestVerdict) GetPassedAvgDuration() *durationpb.Duration {
+	if x != nil {
+		return x.PassedAvgDuration
+	}
+	return nil
+}
+
+var File_infra_appengine_weetbix_proto_v1_test_verdict_proto protoreflect.FileDescriptor
+
+var file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDesc = []byte{
+	0x0a, 0x33, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x67, 0x69, 0x6e,
+	0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f,
+	0x76, 0x31, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x76, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76,
+	0x31, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62,
+	0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f,
+	0x74, 0x6f, 0x22, 0xb3, 0x02, 0x0a, 0x0b, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x72, 0x64, 0x69,
+	0x63, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20,
+	0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x73, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x76,
+	0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28,
+	0x09, 0x52, 0x0b, 0x76, 0x61, 0x72, 0x69, 0x61, 0x6e, 0x74, 0x48, 0x61, 0x73, 0x68, 0x12, 0x23,
+	0x0a, 0x0d, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18,
+	0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x69, 0x6e, 0x76, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f,
+	0x6e, 0x49, 0x64, 0x12, 0x35, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x04, 0x20,
+	0x01, 0x28, 0x0e, 0x32, 0x1d, 0x2e, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2e, 0x76, 0x31,
+	0x2e, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x72, 0x64, 0x69, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74,
+	0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x41, 0x0a, 0x0e, 0x70, 0x61,
+	0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01,
+	0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74,
+	0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d,
+	0x70, 0x61, 0x72, 0x74, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x54, 0x69, 0x6d, 0x65, 0x12, 0x49, 0x0a,
+	0x13, 0x70, 0x61, 0x73, 0x73, 0x65, 0x64, 0x5f, 0x61, 0x76, 0x67, 0x5f, 0x64, 0x75, 0x72, 0x61,
+	0x74, 0x69, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f,
+	0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72,
+	0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x11, 0x70, 0x61, 0x73, 0x73, 0x65, 0x64, 0x41, 0x76, 0x67,
+	0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2a, 0x6a, 0x0a, 0x10, 0x54, 0x65, 0x73, 0x74,
+	0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x22, 0x0a, 0x1e,
+	0x54, 0x45, 0x53, 0x54, 0x5f, 0x52, 0x45, 0x53, 0x55, 0x4c, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54,
+	0x55, 0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00,
+	0x12, 0x08, 0x0a, 0x04, 0x50, 0x41, 0x53, 0x53, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x46, 0x41,
+	0x49, 0x4c, 0x10, 0x02, 0x12, 0x09, 0x0a, 0x05, 0x43, 0x52, 0x41, 0x53, 0x48, 0x10, 0x03, 0x12,
+	0x09, 0x0a, 0x05, 0x41, 0x42, 0x4f, 0x52, 0x54, 0x10, 0x04, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x4b,
+	0x49, 0x50, 0x10, 0x05, 0x2a, 0x8b, 0x01, 0x0a, 0x11, 0x54, 0x65, 0x73, 0x74, 0x56, 0x65, 0x72,
+	0x64, 0x69, 0x63, 0x74, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x23, 0x0a, 0x1f, 0x54, 0x45,
+	0x53, 0x54, 0x5f, 0x56, 0x45, 0x52, 0x44, 0x49, 0x43, 0x54, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55,
+	0x53, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12,
+	0x0e, 0x0a, 0x0a, 0x55, 0x4e, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x12,
+	0x18, 0x0a, 0x14, 0x55, 0x4e, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x4c, 0x59, 0x5f,
+	0x53, 0x4b, 0x49, 0x50, 0x50, 0x45, 0x44, 0x10, 0x14, 0x12, 0x09, 0x0a, 0x05, 0x46, 0x4c, 0x41,
+	0x4b, 0x59, 0x10, 0x1e, 0x12, 0x0e, 0x0a, 0x0a, 0x45, 0x58, 0x4f, 0x4e, 0x45, 0x52, 0x41, 0x54,
+	0x45, 0x44, 0x10, 0x28, 0x12, 0x0c, 0x0a, 0x08, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44,
+	0x10, 0x32, 0x42, 0x2c, 0x5a, 0x2a, 0x69, 0x6e, 0x66, 0x72, 0x61, 0x2f, 0x61, 0x70, 0x70, 0x65,
+	0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x2f, 0x70, 0x72,
+	0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x77, 0x65, 0x65, 0x74, 0x62, 0x69, 0x78, 0x70, 0x62,
+	0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescOnce sync.Once
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescData = file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDesc
+)
+
+func file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescGZIP() []byte {
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescOnce.Do(func() {
+		file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescData = protoimpl.X.CompressGZIP(file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescData)
+	})
+	return file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDescData
+}
+
+var file_infra_appengine_weetbix_proto_v1_test_verdict_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
+var file_infra_appengine_weetbix_proto_v1_test_verdict_proto_msgTypes = make([]protoimpl.MessageInfo, 1)
+var file_infra_appengine_weetbix_proto_v1_test_verdict_proto_goTypes = []interface{}{
+	(TestResultStatus)(0),         // 0: weetbix.v1.TestResultStatus
+	(TestVerdictStatus)(0),        // 1: weetbix.v1.TestVerdictStatus
+	(*TestVerdict)(nil),           // 2: weetbix.v1.TestVerdict
+	(*timestamppb.Timestamp)(nil), // 3: google.protobuf.Timestamp
+	(*durationpb.Duration)(nil),   // 4: google.protobuf.Duration
+}
+var file_infra_appengine_weetbix_proto_v1_test_verdict_proto_depIdxs = []int32{
+	1, // 0: weetbix.v1.TestVerdict.status:type_name -> weetbix.v1.TestVerdictStatus
+	3, // 1: weetbix.v1.TestVerdict.partition_time:type_name -> google.protobuf.Timestamp
+	4, // 2: weetbix.v1.TestVerdict.passed_avg_duration:type_name -> google.protobuf.Duration
+	3, // [3:3] is the sub-list for method output_type
+	3, // [3:3] is the sub-list for method input_type
+	3, // [3:3] is the sub-list for extension type_name
+	3, // [3:3] is the sub-list for extension extendee
+	0, // [0:3] is the sub-list for field type_name
+}
+
+func init() { file_infra_appengine_weetbix_proto_v1_test_verdict_proto_init() }
+func file_infra_appengine_weetbix_proto_v1_test_verdict_proto_init() {
+	if File_infra_appengine_weetbix_proto_v1_test_verdict_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_infra_appengine_weetbix_proto_v1_test_verdict_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*TestVerdict); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDesc,
+			NumEnums:      2,
+			NumMessages:   1,
+			NumExtensions: 0,
+			NumServices:   0,
+		},
+		GoTypes:           file_infra_appengine_weetbix_proto_v1_test_verdict_proto_goTypes,
+		DependencyIndexes: file_infra_appengine_weetbix_proto_v1_test_verdict_proto_depIdxs,
+		EnumInfos:         file_infra_appengine_weetbix_proto_v1_test_verdict_proto_enumTypes,
+		MessageInfos:      file_infra_appengine_weetbix_proto_v1_test_verdict_proto_msgTypes,
+	}.Build()
+	File_infra_appengine_weetbix_proto_v1_test_verdict_proto = out.File
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_rawDesc = nil
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_goTypes = nil
+	file_infra_appengine_weetbix_proto_v1_test_verdict_proto_depIdxs = nil
+}
diff --git a/analysis/proto/v1/test_verdict.proto b/analysis/proto/v1/test_verdict.proto
new file mode 100644
index 0000000..40ffeac
--- /dev/null
+++ b/analysis/proto/v1/test_verdict.proto
@@ -0,0 +1,102 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package weetbix.v1;
+
+import "google/protobuf/duration.proto";
+import "google/protobuf/timestamp.proto";
+
+option go_package = "go.chromium.org/luci/analysis/proto/v1;weetbixpb";
+
+
+// Status of a test result.
+// It is a mirror of luci.resultdb.v1.TestStatus, but the right to evolve
+// it independently is reserved.
+enum TestResultStatus {
+  // Status was not specified.
+  // Not to be used in actual test results; serves as a default value for an
+  // unset field.
+  TEST_RESULT_STATUS_UNSPECIFIED = 0;
+
+  // The test case has passed.
+  PASS = 1;
+
+  // The test case has failed.
+  // Suggests that the code under test is incorrect, but it is also possible
+  // that the test is incorrect or it is a flake.
+  FAIL = 2;
+
+  // The test case has crashed during execution.
+  // The outcome is inconclusive: the code under test might or might not be
+  // correct, but the test+code is incorrect.
+  CRASH = 3;
+
+  // The test case has started, but was aborted before finishing.
+  // A common reason: timeout.
+  ABORT = 4;
+
+  // The test case did not execute.
+  // Examples:
+  // - The execution of the collection of test cases, such as a test
+  //   binary, was aborted prematurely and execution of some test cases was
+  //   skipped.
+  // - The test harness configuration specified that the test case MUST be
+  //   skipped.
+  SKIP = 5;
+}
+
+// Status of a test verdict.
+// It is a mirror of luci.resultdb.v1.TestVariantStatus.
+enum TestVerdictStatus {
+  // a test verdict must not have this status.
+  // This is only used when filtering verdicts.
+  TEST_VERDICT_STATUS_UNSPECIFIED = 0;
+  // The test verdict has no exonerations, and all results are unexpected.
+  UNEXPECTED = 10;
+  // The test verdict has no exonerations, and all results are unexpectedly skipped.
+  UNEXPECTEDLY_SKIPPED = 20;
+  // The test verdict has no exonerations, and has both expected and unexpected
+  // results.
+  FLAKY = 30;
+  // The test verdict has one or more test exonerations.
+  EXONERATED = 40;
+  // The test verdict has no exonerations, and all results are expected.
+  EXPECTED = 50;
+}
+
+message TestVerdict {
+  // Unique identifier of the test.
+  // This has the same value as luci.resultdb.v1.TestResult.test_id.
+  string test_id = 1;
+
+  // The hash of the variant.
+  string variant_hash = 2;
+
+  // The ID of the top-level invocation that the test verdict belongs to when
+  // ingested.
+  string invocation_id = 3;
+
+  // The status of the test verdict.
+  TestVerdictStatus status = 4;
+
+  // Start time of the presubmit run (for results that are part of a presubmit
+  // run) or start time of the buildbucket build (otherwise).
+  google.protobuf.Timestamp partition_time = 5;
+
+  // The average duration of the PASSED test results included in the test
+  // verdict.
+  google.protobuf.Duration passed_avg_duration = 6;
+}
diff --git a/analysis/proto/v1/testhistoryserver_dec.go b/analysis/proto/v1/testhistoryserver_dec.go
new file mode 100644
index 0000000..eabfb1a
--- /dev/null
+++ b/analysis/proto/v1/testhistoryserver_dec.go
@@ -0,0 +1,92 @@
+// Code generated by svcdec; DO NOT EDIT.
+
+package weetbixpb
+
+import (
+	"context"
+
+	proto "github.com/golang/protobuf/proto"
+)
+
+type DecoratedTestHistory struct {
+	// Service is the service to decorate.
+	Service TestHistoryServer
+	// Prelude is called for each method before forwarding the call to Service.
+	// If Prelude returns an error, then the call is skipped and the error is
+	// processed via the Postlude (if one is defined), or it is returned directly.
+	Prelude func(ctx context.Context, methodName string, req proto.Message) (context.Context, error)
+	// Postlude is called for each method after Service has processed the call, or
+	// after the Prelude has returned an error. This takes the the Service's
+	// response proto (which may be nil) and/or any error. The decorated
+	// service will return the response (possibly mutated) and error that Postlude
+	// returns.
+	Postlude func(ctx context.Context, methodName string, rsp proto.Message, err error) error
+}
+
+func (s *DecoratedTestHistory) Query(ctx context.Context, req *QueryTestHistoryRequest) (rsp *QueryTestHistoryResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "Query", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.Query(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "Query", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedTestHistory) QueryStats(ctx context.Context, req *QueryTestHistoryStatsRequest) (rsp *QueryTestHistoryStatsResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "QueryStats", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.QueryStats(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "QueryStats", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedTestHistory) QueryVariants(ctx context.Context, req *QueryVariantsRequest) (rsp *QueryVariantsResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "QueryVariants", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.QueryVariants(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "QueryVariants", rsp, err)
+	}
+	return
+}
+
+func (s *DecoratedTestHistory) QueryTests(ctx context.Context, req *QueryTestsRequest) (rsp *QueryTestsResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "QueryTests", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.QueryTests(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "QueryTests", rsp, err)
+	}
+	return
+}
diff --git a/analysis/proto/v1/testvariantsserver_dec.go b/analysis/proto/v1/testvariantsserver_dec.go
new file mode 100644
index 0000000..30a0f3b
--- /dev/null
+++ b/analysis/proto/v1/testvariantsserver_dec.go
@@ -0,0 +1,41 @@
+// Code generated by svcdec; DO NOT EDIT.
+
+package weetbixpb
+
+import (
+	"context"
+
+	proto "github.com/golang/protobuf/proto"
+)
+
+type DecoratedTestVariants struct {
+	// Service is the service to decorate.
+	Service TestVariantsServer
+	// Prelude is called for each method before forwarding the call to Service.
+	// If Prelude returns an error, then the call is skipped and the error is
+	// processed via the Postlude (if one is defined), or it is returned directly.
+	Prelude func(ctx context.Context, methodName string, req proto.Message) (context.Context, error)
+	// Postlude is called for each method after Service has processed the call, or
+	// after the Prelude has returned an error. This takes the the Service's
+	// response proto (which may be nil) and/or any error. The decorated
+	// service will return the response (possibly mutated) and error that Postlude
+	// returns.
+	Postlude func(ctx context.Context, methodName string, rsp proto.Message, err error) error
+}
+
+func (s *DecoratedTestVariants) QueryFailureRate(ctx context.Context, req *QueryTestVariantFailureRateRequest) (rsp *QueryTestVariantFailureRateResponse, err error) {
+	if s.Prelude != nil {
+		var newCtx context.Context
+		newCtx, err = s.Prelude(ctx, "QueryFailureRate", req)
+		if err == nil {
+			ctx = newCtx
+		}
+	}
+	if err == nil {
+		rsp, err = s.Service.QueryFailureRate(ctx, req)
+	}
+	if s.Postlude != nil {
+		err = s.Postlude(ctx, "QueryFailureRate", rsp, err)
+	}
+	return
+}
diff --git a/analysis/rpc/bugs.go b/analysis/rpc/bugs.go
new file mode 100644
index 0000000..79b46c9
--- /dev/null
+++ b/analysis/rpc/bugs.go
@@ -0,0 +1,59 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"fmt"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func createAssociatedBugPB(b bugs.BugID, cfg *configpb.ProjectConfig) *pb.AssociatedBug {
+	// Fallback bug name and URL.
+	linkText := fmt.Sprintf("%s/%s", b.System, b.ID)
+	url := ""
+
+	switch b.System {
+	case bugs.MonorailSystem:
+		project, id, err := b.MonorailProjectAndID()
+		if err != nil {
+			// Fallback to basic name and blank URL.
+			break
+		}
+		if project == cfg.Monorail.Project {
+			if cfg.Monorail.DisplayPrefix != "" {
+				linkText = fmt.Sprintf("%s/%s", cfg.Monorail.DisplayPrefix, id)
+			} else {
+				linkText = id
+			}
+		}
+		if cfg.Monorail.MonorailHostname != "" {
+			url = fmt.Sprintf("https://%s/p/%s/issues/detail?id=%s", cfg.Monorail.MonorailHostname, project, id)
+		}
+	case bugs.BuganizerSystem:
+		linkText = fmt.Sprintf("b/%s", b.ID)
+		url = fmt.Sprintf("https://issuetracker.google.com/issues/%s", b.ID)
+	default:
+		// Fallback.
+	}
+	return &pb.AssociatedBug{
+		System:   b.System,
+		Id:       b.ID,
+		LinkText: linkText,
+		Url:      url,
+	}
+}
diff --git a/analysis/rpc/clusterid.go b/analysis/rpc/clusterid.go
new file mode 100644
index 0000000..70ed0c5
--- /dev/null
+++ b/analysis/rpc/clusterid.go
@@ -0,0 +1,59 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"strings"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func createClusterIdPB(b clustering.ClusterID) *pb.ClusterId {
+	return &pb.ClusterId{
+		Algorithm: aliasAlgorithm(b.Algorithm),
+		Id:        b.ID,
+	}
+}
+
+func aliasAlgorithm(algorithm string) string {
+	// Drop the version number from the rules algorithm,
+	// e.g. "rules-v2" -> "rules".
+	// Clients may want to identify if the rules algorithm
+	// was used to cluster, to identify clusters for which they
+	// can lookup the corresponding rule.
+	// Hiding the version information avoids clients
+	// accidentally depending on it in their code, which would
+	// make changing the version of the rules-based clustering
+	// algorithm breaking for clients.
+	// It is anticipated that future updates to the rules-based
+	// clustering algorithm will mostly be about tweaking the
+	// failure-matching semantics, and will retain the property
+	// that the Cluster ID corresponds to the Rule ID.
+	if strings.HasPrefix(algorithm, clustering.RulesAlgorithmPrefix) {
+		return "rules"
+	}
+	return algorithm
+}
+
+func resolveAlgorithm(algorithm string) string {
+	// Resolve an alias to the rules algorithm to the concrete
+	// implementation, e.g. "rules" -> "rules-v2".
+	if algorithm == "rules" {
+		return rulesalgorithm.AlgorithmName
+	}
+	return algorithm
+}
diff --git a/analysis/rpc/clusters.go b/analysis/rpc/clusters.go
new file mode 100644
index 0000000..7b07008
--- /dev/null
+++ b/analysis/rpc/clusters.go
@@ -0,0 +1,656 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"encoding/hex"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/common/data/stringset"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/common/sync/parallel"
+	"go.chromium.org/luci/grpc/appstatus"
+	"go.chromium.org/luci/resultdb/rdbperms"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/aip"
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/reclustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules/cache"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	"go.chromium.org/luci/analysis/internal/perms"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// MaxClusterRequestSize is the maximum number of test results to cluster in
+// one call to Cluster(...).
+const MaxClusterRequestSize = 1000
+
+// MaxBatchGetClustersRequestSize is the maximum number of clusters to obtain
+// impact for in one call to BatchGetClusters().
+const MaxBatchGetClustersRequestSize = 1000
+
+type AnalysisClient interface {
+	ReadClusters(ctx context.Context, luciProject string, clusterIDs []clustering.ClusterID) ([]*analysis.Cluster, error)
+	ReadClusterFailures(ctx context.Context, options analysis.ReadClusterFailuresOptions) (cfs []*analysis.ClusterFailure, err error)
+	QueryClusterSummaries(ctx context.Context, luciProject string, options *analysis.QueryClusterSummariesOptions) ([]*analysis.ClusterSummary, error)
+}
+
+type clustersServer struct {
+	analysisClient AnalysisClient
+}
+
+func NewClustersServer(analysisClient AnalysisClient) *pb.DecoratedClusters {
+	return &pb.DecoratedClusters{
+		Prelude:  checkAllowedPrelude,
+		Service:  &clustersServer{analysisClient: analysisClient},
+		Postlude: gRPCifyAndLogPostlude,
+	}
+}
+
+// Cluster clusters a list of test failures. See proto definition for more.
+func (*clustersServer) Cluster(ctx context.Context, req *pb.ClusterRequest) (*pb.ClusterResponse, error) {
+	if !config.ProjectRe.MatchString(req.Project) {
+		return nil, invalidArgumentError(errors.Reason("project").Err())
+	}
+	// We could make an implementation that gracefully degrades if
+	// perms.PermGetRule is not available (i.e. by not returning the
+	// bug associated with a rule cluster), but there is currently no point.
+	// All Weetbix roles currently always grants both permissions together.
+	if err := perms.VerifyProjectPermissions(ctx, req.Project, perms.PermGetClustersByFailure, perms.PermGetRule); err != nil {
+		return nil, err
+	}
+
+	if len(req.TestResults) > MaxClusterRequestSize {
+		return nil, invalidArgumentError(fmt.Errorf(
+			"too many test results: at most %v test results can be clustered in one request", MaxClusterRequestSize))
+	}
+
+	failures := make([]*clustering.Failure, 0, len(req.TestResults))
+	for i, tr := range req.TestResults {
+		if err := validateTestResult(i, tr); err != nil {
+			return nil, err
+		}
+		failures = append(failures, &clustering.Failure{
+			TestID: tr.TestId,
+			Reason: tr.FailureReason,
+		})
+	}
+
+	// Fetch a recent project configuration.
+	// (May be a recent value that was cached.)
+	cfg, err := readProjectConfig(ctx, req.Project)
+	if err != nil {
+		return nil, err
+	}
+
+	// Fetch a recent ruleset.
+	ruleset, err := reclustering.Ruleset(ctx, req.Project, cache.StrongRead)
+	if err != nil {
+		return nil, err
+	}
+
+	// Perform clustering from scratch. (Incremental clustering does not make
+	// sense for this RPC.)
+	existing := algorithms.NewEmptyClusterResults(len(req.TestResults))
+
+	results := algorithms.Cluster(cfg, ruleset, existing, failures)
+
+	// Construct the response proto.
+	clusteredTRs := make([]*pb.ClusterResponse_ClusteredTestResult, 0, len(results.Clusters))
+	for i, r := range results.Clusters {
+		request := req.TestResults[i]
+
+		entries := make([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry, 0, len(r))
+		for _, clusterID := range r {
+			entry := &pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
+				ClusterId: createClusterIdPB(clusterID),
+			}
+			if clusterID.IsBugCluster() {
+				// For bug clusters, the ID of the cluster is also the ID of
+				// the rule that defines it. Use this property to lookup the
+				// associated rule.
+				ruleID := clusterID.ID
+				rule := ruleset.ActiveRulesByID[ruleID]
+				entry.Bug = createAssociatedBugPB(rule.Rule.BugID, cfg.Config)
+			}
+			entries = append(entries, entry)
+		}
+		clusteredTR := &pb.ClusterResponse_ClusteredTestResult{
+			RequestTag: request.RequestTag,
+			Clusters:   entries,
+		}
+		clusteredTRs = append(clusteredTRs, clusteredTR)
+	}
+
+	version := &pb.ClusteringVersion{
+		AlgorithmsVersion: int32(results.AlgorithmsVersion),
+		RulesVersion:      timestamppb.New(results.RulesVersion),
+		ConfigVersion:     timestamppb.New(results.ConfigVersion),
+	}
+
+	return &pb.ClusterResponse{
+		ClusteredTestResults: clusteredTRs,
+		ClusteringVersion:    version,
+	}, nil
+}
+
+func validateTestResult(i int, tr *pb.ClusterRequest_TestResult) error {
+	if tr.TestId == "" {
+		return invalidArgumentError(fmt.Errorf("test result %v: test ID must not be empty", i))
+	}
+	return nil
+}
+
+func (c *clustersServer) BatchGet(ctx context.Context, req *pb.BatchGetClustersRequest) (*pb.BatchGetClustersResponse, error) {
+	project, err := parseProjectName(req.Parent)
+	if err != nil {
+		return nil, invalidArgumentError(errors.Annotate(err, "parent").Err())
+	}
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermGetCluster, perms.PermExpensiveClusterQueries); err != nil {
+		return nil, err
+	}
+
+	if len(req.Names) > MaxBatchGetClustersRequestSize {
+		return nil, invalidArgumentError(fmt.Errorf(
+			"too many names: at most %v clusters can be retrieved in one request", MaxBatchGetClustersRequestSize))
+	}
+	if len(req.Names) == 0 {
+		// Return INVALID_ARGUMENT if no names specified, as per google.aip.dev/231.
+		return nil, invalidArgumentError(errors.New("names must be specified"))
+	}
+
+	cfg, err := readProjectConfig(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	// The cluster ID requested in each request item.
+	clusterIDs := make([]clustering.ClusterID, 0, len(req.Names))
+
+	for i, name := range req.Names {
+		clusterProject, clusterID, err := parseClusterName(name)
+		if err != nil {
+			return nil, invalidArgumentError(errors.Annotate(err, "name %v", i).Err())
+		}
+		if clusterProject != project {
+			return nil, invalidArgumentError(fmt.Errorf("name %v: project must match parent project (%q)", i, project))
+		}
+		clusterIDs = append(clusterIDs, clusterID)
+	}
+
+	clusters, err := c.analysisClient.ReadClusters(ctx, project, clusterIDs)
+	if err != nil {
+		if err == analysis.ProjectNotExistsErr {
+			return nil, appstatus.Error(codes.NotFound,
+				"Weetbix BigQuery dataset not provisioned for project or cluster analysis is not yet available")
+		}
+		return nil, err
+	}
+
+	readClusterByID := make(map[clustering.ClusterID]*analysis.Cluster)
+	for _, c := range clusters {
+		readClusterByID[c.ClusterID] = c
+	}
+
+	readableRealms, err := perms.QueryRealms(ctx, project, nil, rdbperms.PermListTestResults)
+	if err != nil {
+		return nil, err
+	}
+	readableRealmsSet := stringset.NewFromSlice(readableRealms...)
+
+	// As per google.aip.dev/231, the order of responses must be the
+	// same as the names in the request.
+	results := make([]*pb.Cluster, 0, len(clusterIDs))
+	for i, clusterID := range clusterIDs {
+		c, ok := readClusterByID[clusterID]
+		if !ok {
+			c = &analysis.Cluster{
+				ClusterID: clusterID,
+				// No impact available for cluster (e.g. because no examples
+				// in BigQuery). Use suitable default values (all zeros
+				// for impact).
+			}
+		}
+
+		result := &pb.Cluster{
+			Name:       req.Names[i],
+			HasExample: ok,
+			UserClsFailedPresubmit: &pb.Cluster_MetricValues{
+				OneDay:   newCounts(c.PresubmitRejects1d),
+				ThreeDay: newCounts(c.PresubmitRejects3d),
+				SevenDay: newCounts(c.PresubmitRejects7d),
+			},
+			CriticalFailuresExonerated: &pb.Cluster_MetricValues{
+				OneDay:   newCounts(c.CriticalFailuresExonerated1d),
+				ThreeDay: newCounts(c.CriticalFailuresExonerated3d),
+				SevenDay: newCounts(c.CriticalFailuresExonerated7d),
+			},
+			Failures: &pb.Cluster_MetricValues{
+				OneDay:   newCounts(c.Failures1d),
+				ThreeDay: newCounts(c.Failures3d),
+				SevenDay: newCounts(c.Failures7d),
+			},
+		}
+
+		if !clusterID.IsBugCluster() && ok {
+			example := &clustering.Failure{
+				TestID: c.ExampleTestID(),
+				Reason: &pb.FailureReason{
+					PrimaryErrorMessage: c.ExampleFailureReason.StringVal,
+				},
+			}
+
+			// Whether the user has access to at least one test result in the cluster.
+			canSeeAtLeastOneExample := false
+			for _, r := range c.Realms {
+				if readableRealmsSet.Has(r) {
+					canSeeAtLeastOneExample = true
+					break
+				}
+			}
+			if canSeeAtLeastOneExample {
+				// While the user has access to at least one test result in the cluster,
+				// they may not have access to the randomly selected example we retrieved
+				// from the cluster_summaries table. Therefore, we must be careful not
+				// to disclose any aspect of this example other than the
+				// clustering key it has in common with all other examples
+				// in the cluster.
+				hasAccessToGivenExample := false
+				result.Title = suggestedClusterTitle(c.ClusterID, example, hasAccessToGivenExample, cfg)
+				result.EquivalentFailureAssociationRule = failureAssociationRule(c.ClusterID, example, cfg)
+			}
+		}
+		results = append(results, result)
+	}
+	return &pb.BatchGetClustersResponse{
+		Clusters: results,
+	}, nil
+}
+
+func newCounts(counts analysis.Counts) *pb.Cluster_MetricValues_Counts {
+	return &pb.Cluster_MetricValues_Counts{Nominal: counts.Nominal}
+}
+
+// failureAssociationRule returns the failure association rule for the
+// given cluster ID, assuming the provided example is still a current
+// example of the cluster.
+// It is assumed the user does not have access to the specific test
+// result represented by exampleFailure, but does have access to at
+// least one other test result in the cluster. As such, this method
+// must only return aspects of the test result which are common
+// to all test results in this cluster.
+func failureAssociationRule(clusterID clustering.ClusterID, exampleFailure *clustering.Failure, cfg *compiledcfg.ProjectConfig) string {
+	// Ignore error, it is only returned if algorithm cannot be found.
+	alg, _ := algorithms.SuggestingAlgorithm(clusterID.Algorithm)
+	if alg != nil {
+		// Check the example is still in the cluster. Sometimes cluster
+		// examples are stale (e.g. because cluster configuration has
+		// changed and re-clustering is yet to be fully complete and
+		// reflected in the cluster_summaries table).
+		//
+		// If the example is stale, it cannot be used as the basis for
+		// deriving the failure association rule to show to the user.
+		// This is for two reasons:
+		// 1) Functionality. The rule derived from the example
+		//    would not be the correct rule for this cluster.
+		// 2) Security. The example failure provided may not be from a realm
+		//    the user has access to. As a result of a configuration change,
+		//    it may now be in a new cluster.
+		//    There is no guarantee the user has access to any test results
+		//    in this new cluster, even if it contains some of the test results
+		//    of the old cluster, which the user could see some examples of.
+		//    The failure association rule for the new cluster is one that the
+		//    user may not be allowed to see.
+		exampleClusterID := hex.EncodeToString(alg.Cluster(cfg, exampleFailure))
+		if exampleClusterID == clusterID.ID {
+			return alg.FailureAssociationRule(cfg, exampleFailure)
+		}
+	}
+	return ""
+}
+
+// suggestedClusterTitle returns a human-readable description of the cluster,
+// using an example failure to help recover the unhashed clustering key.
+// hasAccessToGivenExample indicates if the user has permission to see the specific
+// example of the cluster (exampleFailure), or (if false) whether they can
+// only see one example (but not necessarily exampleFailure).
+// If it is false, the result of this method will not contain any aspects
+// of the test result other than the aspects which are common to all other
+// test results in the cluster (i.e. the clustering key).
+func suggestedClusterTitle(clusterID clustering.ClusterID, exampleFailure *clustering.Failure, hasAccessToGivenExample bool, cfg *compiledcfg.ProjectConfig) string {
+	// Ignore error, it is only returned if algorithm cannot be found.
+	alg, _ := algorithms.SuggestingAlgorithm(clusterID.Algorithm)
+	if alg != nil {
+		// Check the example is still in the cluster. Sometimes cluster
+		// examples are stale (e.g. because cluster configuration has
+		// changed and re-clustering is yet to be fully complete and
+		// reflected in the cluster_summaries table).
+		//
+		// If the example is stale, it cannot be used as the basis for
+		// deriving the clustering key (cluster definition) to show to
+		// the user. This is for two reasons:
+		// 1) Functionality. The clustering key derived from the example
+		//    would not be the correct clustering key for this cluster.
+		// 2) Security. The example failure provided may not be from a realm
+		//    the user has access to. As a result of a configuration change,
+		//    it may now be in a new cluster.
+		//    There is no guarantee the user has access to any test results
+		//    in this new cluster, even if it contains some of the test results
+		//    of the current cluster, which the user could see some examples of.
+		//    The failure association rule for the new cluster is one that the
+		//    user may not be allowed to see.
+		exampleClusterID := hex.EncodeToString(alg.Cluster(cfg, exampleFailure))
+		if exampleClusterID == clusterID.ID {
+			return alg.ClusterKey(cfg, exampleFailure)
+		}
+	}
+	// Fallback.
+	if hasAccessToGivenExample {
+		// The user has access to the specific test result used as an example.
+		// We are fine to disclose it; we do not have to rely on sanitising it
+		// down to the common clustering key.
+		if clusterID.IsTestNameCluster() {
+			// Fallback for old test name clusters.
+			return exampleFailure.TestID
+		}
+		if clusterID.IsFailureReasonCluster() {
+			// Fallback for old reason-based clusters.
+			return exampleFailure.Reason.PrimaryErrorMessage
+		}
+	}
+	// Fallback for all other cases.
+	return "(definition unavailable due to ongoing reclustering)"
+}
+
+func (c *clustersServer) GetReclusteringProgress(ctx context.Context, req *pb.GetReclusteringProgressRequest) (*pb.ReclusteringProgress, error) {
+	project, err := parseReclusteringProgressName(req.Name)
+	if err != nil {
+		return nil, invalidArgumentError(errors.Annotate(err, "name").Err())
+	}
+	// Getting reclustering progress is considered part of getting a cluster:
+	// whenever you retrieve a cluster, you should be able to tell if the
+	// information you are reading is up to date.
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermGetCluster); err != nil {
+		return nil, err
+	}
+
+	progress, err := runs.ReadReclusteringProgress(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.ReclusteringProgress{
+		Name:             req.Name,
+		ProgressPerMille: int32(progress.ProgressPerMille),
+		Last: &pb.ClusteringVersion{
+			AlgorithmsVersion: int32(progress.Last.AlgorithmsVersion),
+			RulesVersion:      timestamppb.New(progress.Last.RulesVersion),
+			ConfigVersion:     timestamppb.New(progress.Last.ConfigVersion),
+		},
+		Next: &pb.ClusteringVersion{
+			AlgorithmsVersion: int32(progress.Next.AlgorithmsVersion),
+			RulesVersion:      timestamppb.New(progress.Next.RulesVersion),
+			ConfigVersion:     timestamppb.New(progress.Next.ConfigVersion),
+		},
+	}, nil
+}
+
+func (c *clustersServer) QueryClusterSummaries(ctx context.Context, req *pb.QueryClusterSummariesRequest) (*pb.QueryClusterSummariesResponse, error) {
+	if !config.ProjectRe.MatchString(req.Project) {
+		return nil, invalidArgumentError(errors.Reason("project").Err())
+	}
+
+	// TODO(b/239768873): Provide some sort of fallback for users who do not
+	// have permission to run expensive queries if no filters are applied.
+
+	// We could make an implementation that gracefully deals with the situation where the user
+	// does not have perms.PermGetRule, but there is currently no point as the Weetbix reader
+	// role currently always grants PermGetRule with PermListClusters.
+	if err := perms.VerifyProjectPermissions(ctx, req.Project, perms.PermListClusters, perms.PermExpensiveClusterQueries, perms.PermGetRule); err != nil {
+		return nil, err
+	}
+	canSeeRuleDefinition, err := perms.HasProjectPermission(ctx, req.Project, perms.PermGetRuleDefinition)
+	if err != nil {
+		return nil, err
+	}
+
+	var cfg *compiledcfg.ProjectConfig
+	var ruleset *cache.Ruleset
+	var clusters []*analysis.ClusterSummary
+	var bqErr error
+	// Parallelise call to Biquery (slow call)
+	// with the datastore/spanner calls to reduce the critical path.
+	err = parallel.FanOutIn(func(ch chan<- func() error) {
+		ch <- func() error {
+			start := time.Now()
+			var err error
+			// Fetch a recent project configuration.
+			// (May be a recent value that was cached.)
+			cfg, err = readProjectConfig(ctx, req.Project)
+			if err != nil {
+				return err
+			}
+
+			// Fetch a recent ruleset.
+			ruleset, err = reclustering.Ruleset(ctx, req.Project, cache.StrongRead)
+			if err != nil {
+				return err
+			}
+			logging.Infof(ctx, "QueryClusterSummaries: Ruleset part took %v", time.Since(start))
+			return nil
+		}
+		ch <- func() error {
+			start := time.Now()
+			// To avoid the error returned from the service being non-deterministic
+			// if both goroutines error, populate any error encountered here
+			// into bqErr and return no error.
+			opts := &analysis.QueryClusterSummariesOptions{}
+			var err error
+			opts.FailureFilter, err = aip.ParseFilter(req.FailureFilter)
+			if err != nil {
+				bqErr = invalidArgumentError(errors.Annotate(err, "failure_filter").Err())
+				return nil
+			}
+			opts.OrderBy, err = aip.ParseOrderBy(req.OrderBy)
+			if err != nil {
+				bqErr = invalidArgumentError(errors.Annotate(err, "order_by").Err())
+				return nil
+			}
+			opts.Realms, err = perms.QueryRealmsNonEmpty(ctx, req.Project, nil, perms.ListTestResultsAndExonerations...)
+			if err != nil {
+				bqErr = err
+				return nil
+			}
+
+			clusters, err = c.analysisClient.QueryClusterSummaries(ctx, req.Project, opts)
+			if err != nil {
+				if err == analysis.ProjectNotExistsErr {
+					bqErr = appstatus.Error(codes.NotFound,
+						"Weetbix BigQuery dataset not provisioned for project or cluster analysis is not yet available")
+					return nil
+				}
+				if analysis.InvalidArgumentTag.In(err) {
+					bqErr = invalidArgumentError(err)
+					return nil
+				}
+				bqErr = errors.Annotate(err, "query clusters for failures").Err()
+				return nil
+			}
+			logging.Infof(ctx, "QueryClusterSummaries: BigQuery part took %v", time.Since(start))
+			return nil
+		}
+	})
+	if err != nil {
+		return nil, err
+	}
+	// To avoid the error returned from the service being non-deterministic
+	// if both goroutines error, return error from bigQuery part after any other errors.
+	if bqErr != nil {
+		return nil, bqErr
+	}
+
+	result := []*pb.ClusterSummary{}
+	for _, c := range clusters {
+		cs := &pb.ClusterSummary{
+			ClusterId:                  createClusterIdPB(c.ClusterID),
+			PresubmitRejects:           c.PresubmitRejects,
+			CriticalFailuresExonerated: c.CriticalFailuresExonerated,
+			Failures:                   c.Failures,
+		}
+		if c.ClusterID.IsBugCluster() {
+			ruleID := c.ClusterID.ID
+			rule := ruleset.ActiveRulesByID[ruleID]
+			if rule != nil {
+				cs.Bug = createAssociatedBugPB(rule.Rule.BugID, cfg.Config)
+				if canSeeRuleDefinition {
+					cs.Title = rule.Rule.RuleDefinition
+				} else {
+					// Because the query is limited to running over the test
+					// failures the user has access to, they have permission
+					// to see the example Test ID for the cluster.
+
+					// Attempt to provide a description of the failures matched
+					// by the rule from the data the user can see, without
+					// revealing the content of the rule itself.
+					cs.Title = fmt.Sprintf("Selected failures in %s", c.ExampleTestID)
+					if c.UniqueTestIDs > 1 {
+						cs.Title += fmt.Sprintf(" (and %v more)", c.UniqueTestIDs-1)
+					}
+				}
+			} else {
+				// Rule is inactive / in process of being archived.
+				cs.Title = "(rule archived)"
+			}
+		} else {
+			example := &clustering.Failure{
+				TestID: c.ExampleTestID,
+				Reason: &pb.FailureReason{
+					PrimaryErrorMessage: c.ExampleFailureReason.StringVal,
+				},
+			}
+			// Because QueryClusterSummaries only reads failures the user has
+			// access to, the example is one the user has access to, and
+			// so we can use it for the title.
+			hasAccessToGivenExample := true
+			cs.Title = suggestedClusterTitle(c.ClusterID, example, hasAccessToGivenExample, cfg)
+		}
+
+		result = append(result, cs)
+	}
+	return &pb.QueryClusterSummariesResponse{ClusterSummaries: result}, nil
+}
+
+func (c *clustersServer) QueryClusterFailures(ctx context.Context, req *pb.QueryClusterFailuresRequest) (*pb.QueryClusterFailuresResponse, error) {
+	project, clusterID, err := parseClusterFailuresName(req.Parent)
+	if err != nil {
+		return nil, invalidArgumentError(errors.Annotate(err, "parent").Err())
+	}
+
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermGetCluster, perms.PermExpensiveClusterQueries); err != nil {
+		return nil, err
+	}
+	opts := analysis.ReadClusterFailuresOptions{
+		Project:   project,
+		ClusterID: clusterID,
+	}
+	opts.Realms, err = perms.QueryRealmsNonEmpty(ctx, project, nil, perms.ListTestResultsAndExonerations...)
+	if err != nil {
+		// If the user has permission in no realms, QueryRealmsNonEmpty
+		// will return an appstatus error PERMISSION_DENIED.
+		// Otherwise, e.g. in case AuthDB was unavailable, the error will
+		// not be an appstatus error and the client will get an internal
+		// server error.
+		return nil, err
+	}
+
+	failures, err := c.analysisClient.ReadClusterFailures(ctx, opts)
+	if err != nil {
+		if err == analysis.ProjectNotExistsErr {
+			return nil, appstatus.Error(codes.NotFound,
+				"Weetbix BigQuery dataset not provisioned for project or clustered failures not yet available")
+		}
+		return nil, errors.Annotate(err, "query cluster failures").Err()
+	}
+	response := &pb.QueryClusterFailuresResponse{}
+	for _, f := range failures {
+		response.Failures = append(response.Failures, createDistinctClusterFailurePB(f))
+	}
+
+	return response, nil
+}
+
+func createDistinctClusterFailurePB(f *analysis.ClusterFailure) *pb.DistinctClusterFailure {
+	var exonerations []*pb.DistinctClusterFailure_Exoneration
+	for _, ex := range f.Exonerations {
+		exonerations = append(exonerations, &pb.DistinctClusterFailure_Exoneration{
+			Reason: analysis.FromBQExonerationReason(ex.Reason.StringVal),
+		})
+	}
+
+	var changelists []*pb.Changelist
+	for _, cl := range f.Changelists {
+		changelists = append(changelists, &pb.Changelist{
+			Host:     cl.Host.StringVal,
+			Change:   cl.Change.Int64,
+			Patchset: int32(cl.Patchset.Int64),
+		})
+	}
+
+	buildStatus := analysis.FromBQBuildStatus(f.BuildStatus.StringVal)
+
+	var presubmitRun *pb.DistinctClusterFailure_PresubmitRun
+	if f.PresubmitRunID != nil {
+		presubmitRun = &pb.DistinctClusterFailure_PresubmitRun{
+			PresubmitRunId: &pb.PresubmitRunId{
+				System: f.PresubmitRunID.System.StringVal,
+				Id:     f.PresubmitRunID.ID.StringVal,
+			},
+			Owner: f.PresubmitRunOwner.StringVal,
+			Mode:  analysis.FromBQPresubmitRunMode(f.PresubmitRunMode.StringVal),
+		}
+	}
+
+	variantDef := make(map[string]string)
+	for _, v := range f.Variant {
+		variantDef[v.Key.StringVal] = v.Value.StringVal
+	}
+	var variant *pb.Variant
+	if len(variantDef) > 0 {
+		variant = &pb.Variant{Def: variantDef}
+	}
+
+	return &pb.DistinctClusterFailure{
+		TestId:                      f.TestID.StringVal,
+		Variant:                     variant,
+		PartitionTime:               timestamppb.New(f.PartitionTime.Timestamp),
+		PresubmitRun:                presubmitRun,
+		IsBuildCritical:             f.IsBuildCritical.Bool,
+		Exonerations:                exonerations,
+		BuildStatus:                 buildStatus,
+		IngestedInvocationId:        f.IngestedInvocationID.StringVal,
+		IsIngestedInvocationBlocked: f.IsIngestedInvocationBlocked.Bool,
+		Changelists:                 changelists,
+		Count:                       f.Count,
+	}
+}
diff --git a/analysis/rpc/clusters_test.go b/analysis/rpc/clusters_test.go
new file mode 100644
index 0000000..1a893f1
--- /dev/null
+++ b/analysis/rpc/clusters_test.go
@@ -0,0 +1,1299 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"encoding/hex"
+	"sort"
+	"testing"
+	"time"
+
+	"cloud.google.com/go/bigquery"
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/data/stringset"
+	"go.chromium.org/luci/common/errors"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/resultdb/rdbperms"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/auth/realms"
+	"go.chromium.org/luci/server/caching"
+	"go.chromium.org/luci/server/secrets"
+	"go.chromium.org/luci/server/secrets/testsecrets"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/analysis"
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/failurereason"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/rulesalgorithm"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/clustering/runs"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	"go.chromium.org/luci/analysis/internal/perms"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/pbutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestClusters(t *testing.T) {
+	Convey("With a clusters server", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+		ctx = caching.WithEmptyProcessCache(ctx)
+
+		// For user identification.
+		ctx = authtest.MockAuthConfig(ctx)
+		authState := &authtest.FakeState{
+			Identity:       "user:someone@example.com",
+			IdentityGroups: []string{"weetbix-access"},
+		}
+		ctx = auth.WithState(ctx, authState)
+		ctx = secrets.Use(ctx, &testsecrets.Store{})
+
+		// Provides datastore implementation needed for project config.
+		ctx = memory.Use(ctx)
+		analysisClient := newFakeAnalysisClient()
+		server := NewClustersServer(analysisClient)
+
+		configVersion := time.Date(2025, time.August, 12, 0, 1, 2, 3, time.UTC)
+		projectCfg := config.CreatePlaceholderProjectConfig()
+		projectCfg.LastUpdated = timestamppb.New(configVersion)
+		projectCfg.Monorail.DisplayPrefix = "crbug.com"
+		projectCfg.Monorail.MonorailHostname = "bugs.chromium.org"
+
+		configs := make(map[string]*configpb.ProjectConfig)
+		configs["testproject"] = projectCfg
+		err := config.SetTestProjectConfig(ctx, configs)
+		So(err, ShouldBeNil)
+
+		compiledTestProjectCfg, err := compiledcfg.NewConfig(projectCfg)
+		So(err, ShouldBeNil)
+
+		// Rules version is in microsecond granularity, consistent with
+		// the granularity of Spanner commit timestamps.
+		rulesVersion := time.Date(2021, time.February, 12, 1, 2, 4, 5000, time.UTC)
+		rs := []*rules.FailureAssociationRule{
+			rules.NewRule(0).
+				WithProject("testproject").
+				WithRuleDefinition(`test LIKE "%TestSuite.TestName%"`).
+				WithPredicateLastUpdated(rulesVersion.Add(-1 * time.Hour)).
+				WithBug(bugs.BugID{
+					System: "monorail",
+					ID:     "chromium/7654321",
+				}).Build(),
+			rules.NewRule(1).
+				WithProject("testproject").
+				WithRuleDefinition(`reason LIKE "my_file.cc(%): Check failed: false."`).
+				WithPredicateLastUpdated(rulesVersion).
+				WithBug(bugs.BugID{
+					System: "buganizer",
+					ID:     "82828282",
+				}).Build(),
+			rules.NewRule(2).
+				WithProject("testproject").
+				WithRuleDefinition(`test LIKE "%Other%"`).
+				WithPredicateLastUpdated(rulesVersion.Add(-2 * time.Hour)).
+				WithBug(bugs.BugID{
+					System: "monorail",
+					ID:     "chromium/912345",
+				}).Build(),
+		}
+		err = rules.SetRulesForTesting(ctx, rs)
+		So(err, ShouldBeNil)
+
+		Convey("Unauthorised requests are rejected", func() {
+			// Ensure no access to weetbix-access.
+			ctx = auth.WithState(ctx, &authtest.FakeState{
+				Identity: "user:someone@example.com",
+				// Not a member of weetbix-access.
+				IdentityGroups: []string{"other-group"},
+			})
+
+			// Make some request (the request should not matter, as
+			// a common decorator is used for all requests.)
+			request := &pb.ClusterRequest{
+				Project: "testproject",
+			}
+
+			rule, err := server.Cluster(ctx, request)
+			So(err, ShouldBeRPCPermissionDenied, "not a member of weetbix-access")
+			So(rule, ShouldBeNil)
+		})
+		Convey("Cluster", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetClustersByFailure,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetRule,
+				},
+			}
+
+			request := &pb.ClusterRequest{
+				Project: "testproject",
+				TestResults: []*pb.ClusterRequest_TestResult{
+					{
+						RequestTag: "my tag 1",
+						TestId:     "ninja://chrome/test:interactive_ui_tests/TestSuite.TestName",
+						FailureReason: &pb.FailureReason{
+							PrimaryErrorMessage: "my_file.cc(123): Check failed: false.",
+						},
+					},
+					{
+						RequestTag: "my tag 2",
+						TestId:     "Other_test",
+					},
+				},
+			}
+
+			Convey("Not authorised to cluster", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetClustersByFailure)
+
+				response, err := server.Cluster(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.getByFailure")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to get rule", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRule)
+
+				response, err := server.Cluster(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.rules.get")
+				So(response, ShouldBeNil)
+			})
+			Convey("With a valid request", func() {
+				// Run
+				response, err := server.Cluster(ctx, request)
+
+				// Verify
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, &pb.ClusterResponse{
+					ClusteredTestResults: []*pb.ClusterResponse_ClusteredTestResult{
+						{
+							RequestTag: "my tag 1",
+							Clusters: sortClusterEntries([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
+								{
+									ClusterId: &pb.ClusterId{
+										Algorithm: "rules",
+										Id:        rs[0].RuleID,
+									},
+									Bug: &pb.AssociatedBug{
+										System:   "monorail",
+										Id:       "chromium/7654321",
+										LinkText: "crbug.com/7654321",
+										Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=7654321",
+									},
+								}, {
+									ClusterId: &pb.ClusterId{
+										Algorithm: "rules",
+										Id:        rs[1].RuleID,
+									},
+									Bug: &pb.AssociatedBug{
+										System:   "buganizer",
+										Id:       "82828282",
+										LinkText: "b/82828282",
+										Url:      "https://issuetracker.google.com/issues/82828282",
+									},
+								},
+								failureReasonClusterEntry(compiledTestProjectCfg, "my_file.cc(123): Check failed: false."),
+								testNameClusterEntry(compiledTestProjectCfg, "ninja://chrome/test:interactive_ui_tests/TestSuite.TestName"),
+							}),
+						},
+						{
+							RequestTag: "my tag 2",
+							Clusters: sortClusterEntries([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
+								{
+									ClusterId: &pb.ClusterId{
+										Algorithm: "rules",
+										Id:        rs[2].RuleID,
+									},
+									Bug: &pb.AssociatedBug{
+										System:   "monorail",
+										Id:       "chromium/912345",
+										LinkText: "crbug.com/912345",
+										Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=912345",
+									},
+								},
+								testNameClusterEntry(compiledTestProjectCfg, "Other_test"),
+							}),
+						},
+					},
+					ClusteringVersion: &pb.ClusteringVersion{
+						AlgorithmsVersion: algorithms.AlgorithmsVersion,
+						RulesVersion:      timestamppb.New(rulesVersion),
+						ConfigVersion:     timestamppb.New(configVersion),
+					},
+				})
+			})
+			Convey("With missing test ID", func() {
+				request.TestResults[1].TestId = ""
+
+				// Run
+				response, err := server.Cluster(ctx, request)
+
+				// Verify
+				So(response, ShouldBeNil)
+				So(err, ShouldBeRPCInvalidArgument, "test result 1: test ID must not be empty")
+			})
+			Convey("With too many test results", func() {
+				var testResults []*pb.ClusterRequest_TestResult
+				for i := 0; i < 1001; i++ {
+					testResults = append(testResults, &pb.ClusterRequest_TestResult{
+						TestId: "AnotherTest",
+					})
+				}
+				request.TestResults = testResults
+
+				// Run
+				response, err := server.Cluster(ctx, request)
+
+				// Verify
+				So(response, ShouldBeNil)
+				So(err, ShouldBeRPCInvalidArgument, "too many test results: at most 1000 test results can be clustered in one request")
+			})
+			Convey("With project not configured", func() {
+				err := config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
+				So(err, ShouldBeNil)
+
+				// Run
+				response, err := server.Cluster(ctx, request)
+
+				// Verify
+				So(response, ShouldBeNil)
+				So(err, ShouldBeRPCFailedPrecondition, "project does not exist in Weetbix")
+			})
+		})
+		Convey("BatchGet", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetCluster,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermExpensiveClusterQueries,
+				},
+				{
+					Realm:      "testproject:realm1",
+					Permission: rdbperms.PermListTestResults,
+				},
+				{
+					Realm:      "testproject:realm3",
+					Permission: rdbperms.PermListTestResults,
+				},
+			}
+
+			example := &clustering.Failure{
+				TestID: "TestID_Example",
+				Reason: &pb.FailureReason{
+					PrimaryErrorMessage: "Example failure reason 123.",
+				},
+			}
+			a := &failurereason.Algorithm{}
+			reasonClusterID := a.Cluster(compiledTestProjectCfg, example)
+
+			analysisClient.clustersByProject["testproject"] = []*analysis.Cluster{
+				{
+					ClusterID: clustering.ClusterID{
+						Algorithm: rulesalgorithm.AlgorithmName,
+						ID:        "11111100000000000000000000000000",
+					},
+					PresubmitRejects1d:           analysis.Counts{Nominal: 1},
+					PresubmitRejects3d:           analysis.Counts{Nominal: 2},
+					PresubmitRejects7d:           analysis.Counts{Nominal: 3},
+					CriticalFailuresExonerated1d: analysis.Counts{Nominal: 4},
+					CriticalFailuresExonerated3d: analysis.Counts{Nominal: 5},
+					CriticalFailuresExonerated7d: analysis.Counts{Nominal: 6},
+					Failures1d:                   analysis.Counts{Nominal: 7},
+					Failures3d:                   analysis.Counts{Nominal: 8},
+					Failures7d:                   analysis.Counts{Nominal: 9},
+					ExampleFailureReason:         bigquery.NullString{Valid: true, StringVal: "Example failure reason."},
+					TopTestIDs: []analysis.TopCount{
+						{Value: "TestID 1", Count: 2},
+						{Value: "TestID 2", Count: 1},
+					},
+					Realms: []string{"testproject:realm1", "testproject:realm2"},
+				},
+				{
+					ClusterID: clustering.ClusterID{
+						Algorithm: testname.AlgorithmName,
+						ID:        "cccccc00000000000000000000000001",
+					},
+					PresubmitRejects7d:   analysis.Counts{Nominal: 11},
+					ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason 2."},
+					TopTestIDs: []analysis.TopCount{
+						{Value: "TestID 3", Count: 2},
+					},
+					Realms: []string{"testproject:realm2", "testproject:realm3"},
+				},
+				{
+					ClusterID: clustering.ClusterID{
+						Algorithm: failurereason.AlgorithmName,
+						ID:        hex.EncodeToString(reasonClusterID),
+					},
+					PresubmitRejects7d:   analysis.Counts{Nominal: 15},
+					ExampleFailureReason: bigquery.NullString{Valid: true, StringVal: "Example failure reason 123."},
+					TopTestIDs: []analysis.TopCount{
+						{Value: "TestID_Example", Count: 10},
+					},
+					Realms: []string{"testproject:realm1", "testproject:realm3"},
+				},
+			}
+
+			request := &pb.BatchGetClustersRequest{
+				Parent: "projects/testproject",
+				Names: []string{
+					// Rule for which data exists.
+					"projects/testproject/clusters/rules/11111100000000000000000000000000",
+
+					// Rule for which no data exists.
+					"projects/testproject/clusters/rules/1111110000000000000000000000ffff",
+
+					// Suggested cluster for which cluster ID matches the example
+					// provided for the cluster.
+					"projects/testproject/clusters/" + failurereason.AlgorithmName + "/" + hex.EncodeToString(reasonClusterID),
+
+					// Suggested cluster for which data exists, but cluster ID mismatches
+					// the example provided for the cluster. This could be because
+					// configuration has changed and re-clustering is not yet complete.
+					"projects/testproject/clusters/" + testname.AlgorithmName + "/cccccc00000000000000000000000001",
+
+					// Suggested cluster for which no impact data exists.
+					"projects/testproject/clusters/reason-v3/cccccc0000000000000000000000ffff",
+				},
+			}
+
+			expectedResponse := &pb.BatchGetClustersResponse{
+				Clusters: []*pb.Cluster{
+					{
+						Name:       "projects/testproject/clusters/rules/11111100000000000000000000000000",
+						HasExample: true,
+						UserClsFailedPresubmit: &pb.Cluster_MetricValues{
+							OneDay:   &pb.Cluster_MetricValues_Counts{Nominal: 1},
+							ThreeDay: &pb.Cluster_MetricValues_Counts{Nominal: 2},
+							SevenDay: &pb.Cluster_MetricValues_Counts{Nominal: 3},
+						},
+						CriticalFailuresExonerated: &pb.Cluster_MetricValues{
+							OneDay:   &pb.Cluster_MetricValues_Counts{Nominal: 4},
+							ThreeDay: &pb.Cluster_MetricValues_Counts{Nominal: 5},
+							SevenDay: &pb.Cluster_MetricValues_Counts{Nominal: 6},
+						},
+						Failures: &pb.Cluster_MetricValues{
+							OneDay:   &pb.Cluster_MetricValues_Counts{Nominal: 7},
+							ThreeDay: &pb.Cluster_MetricValues_Counts{Nominal: 8},
+							SevenDay: &pb.Cluster_MetricValues_Counts{Nominal: 9},
+						},
+					},
+					{
+						Name:                       "projects/testproject/clusters/rules/1111110000000000000000000000ffff",
+						HasExample:                 false,
+						UserClsFailedPresubmit:     emptyMetricValues(),
+						CriticalFailuresExonerated: emptyMetricValues(),
+						Failures:                   emptyMetricValues(),
+					},
+					{
+						Name:       "projects/testproject/clusters/" + failurereason.AlgorithmName + "/" + hex.EncodeToString(reasonClusterID),
+						Title:      "Example failure reason %.",
+						HasExample: true,
+						UserClsFailedPresubmit: &pb.Cluster_MetricValues{
+							OneDay:   &pb.Cluster_MetricValues_Counts{},
+							ThreeDay: &pb.Cluster_MetricValues_Counts{},
+							SevenDay: &pb.Cluster_MetricValues_Counts{Nominal: 15},
+						},
+						CriticalFailuresExonerated:       emptyMetricValues(),
+						Failures:                         emptyMetricValues(),
+						EquivalentFailureAssociationRule: `reason LIKE "Example failure reason %."`,
+					},
+					{
+						Name:       "projects/testproject/clusters/" + testname.AlgorithmName + "/cccccc00000000000000000000000001",
+						Title:      "(definition unavailable due to ongoing reclustering)",
+						HasExample: true,
+						UserClsFailedPresubmit: &pb.Cluster_MetricValues{
+							OneDay:   &pb.Cluster_MetricValues_Counts{},
+							ThreeDay: &pb.Cluster_MetricValues_Counts{},
+							SevenDay: &pb.Cluster_MetricValues_Counts{Nominal: 11},
+						},
+						CriticalFailuresExonerated:       emptyMetricValues(),
+						Failures:                         emptyMetricValues(),
+						EquivalentFailureAssociationRule: ``,
+					},
+					{
+						Name:                       "projects/testproject/clusters/reason-v3/cccccc0000000000000000000000ffff",
+						HasExample:                 false,
+						UserClsFailedPresubmit:     emptyMetricValues(),
+						CriticalFailuresExonerated: emptyMetricValues(),
+						Failures:                   emptyMetricValues(),
+					},
+				},
+			}
+
+			Convey("Not authorised to get cluster", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
+
+				response, err := server.BatchGet(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.get")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to perform expensive queries", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermExpensiveClusterQueries)
+
+				response, err := server.BatchGet(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.expensiveQueries")
+				So(response, ShouldBeNil)
+			})
+			Convey("With a valid request", func() {
+				Convey("No duplicate requests", func() {
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, expectedResponse)
+				})
+				Convey("No test result list permission", func() {
+					authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					for _, r := range expectedResponse.Clusters {
+						r.Title = ""
+						r.EquivalentFailureAssociationRule = ""
+					}
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, expectedResponse)
+				})
+				Convey("Duplicate requests", func() {
+					// Even if request items are duplicated, the request
+					// should still succeed and return correct results.
+					request.Names = append(request.Names, request.Names...)
+					expectedResponse.Clusters = append(expectedResponse.Clusters, expectedResponse.Clusters...)
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, expectedResponse)
+				})
+			})
+			Convey("With invalid request", func() {
+				Convey("Invalid parent", func() {
+					request.Parent = "blah"
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "parent: invalid project name, expected format: projects/{project}")
+				})
+				Convey("No names specified", func() {
+					request.Names = []string{}
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "names must be specified")
+				})
+				Convey("Parent does not match request items", func() {
+					// Request asks for project "blah" but parent asks for
+					// project "testproject".
+					So(request.Parent, ShouldEqual, "projects/testproject")
+					request.Names[1] = "projects/blah/clusters/reason-v3/cccccc00000000000000000000000001"
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, `name 1: project must match parent project ("testproject")`)
+				})
+				Convey("Invalid name", func() {
+					request.Names[1] = "invalid"
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "name 1: invalid cluster name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}")
+				})
+				Convey("Invalid cluster algorithm in name", func() {
+					request.Names[1] = "projects/blah/clusters/reason/cccccc00000000000000000000000001"
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "name 1: invalid cluster identity: algorithm not valid")
+				})
+				Convey("Invalid cluster ID in name", func() {
+					request.Names[1] = "projects/blah/clusters/reason-v3/123"
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "name 1: invalid cluster identity: ID is not valid lowercase hexadecimal bytes")
+				})
+				Convey("Too many request items", func() {
+					var names []string
+					for i := 0; i < 1001; i++ {
+						names = append(names, "projects/testproject/clusters/rules/11111100000000000000000000000000")
+					}
+					request.Names = names
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "too many names: at most 1000 clusters can be retrieved in one request")
+				})
+				Convey("Dataset does not exist", func() {
+					delete(analysisClient.clustersByProject, "testproject")
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCNotFound, "Weetbix BigQuery dataset not provisioned for project or cluster analysis is not yet available")
+				})
+				Convey("With project not configured", func() {
+					err := config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
+					So(err, ShouldBeNil)
+
+					// Run
+					response, err := server.BatchGet(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCFailedPrecondition, "project does not exist in Weetbix")
+				})
+			})
+		})
+		Convey("QueryClusterSummaries", func() {
+			authState.IdentityPermissions = listTestResultsPermissions(
+				"testproject:realm1",
+				"testproject:realm2",
+				"otherproject:realm3",
+			)
+			authState.IdentityPermissions = append(authState.IdentityPermissions, []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermListClusters,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermExpensiveClusterQueries,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetRule,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetRuleDefinition,
+				},
+			}...)
+
+			analysisClient.clusterMetricsByProject["testproject"] = []*analysis.ClusterSummary{
+				{
+					ClusterID: clustering.ClusterID{
+						Algorithm: rulesalgorithm.AlgorithmName,
+						ID:        rs[0].RuleID,
+					},
+					PresubmitRejects:           1,
+					CriticalFailuresExonerated: 2,
+					Failures:                   3,
+					ExampleFailureReason:       bigquery.NullString{Valid: true, StringVal: "Example failure reason."},
+					ExampleTestID:              "TestID 1",
+				},
+				{
+					ClusterID: clustering.ClusterID{
+						Algorithm: "reason-v3",
+						ID:        "cccccc00000000000000000000000001",
+					},
+					PresubmitRejects:           4,
+					CriticalFailuresExonerated: 5,
+					Failures:                   6,
+					ExampleFailureReason:       bigquery.NullString{Valid: true, StringVal: "Example failure reason 2."},
+					ExampleTestID:              "TestID 3",
+				},
+				{
+					ClusterID: clustering.ClusterID{
+						// Rule that is no longer active.
+						Algorithm: rulesalgorithm.AlgorithmName,
+						ID:        "01234567890abcdef01234567890abcdef",
+					},
+					PresubmitRejects:           7,
+					CriticalFailuresExonerated: 8,
+					Failures:                   9,
+					ExampleFailureReason:       bigquery.NullString{Valid: true, StringVal: "Example failure reason."},
+					ExampleTestID:              "TestID 1",
+				},
+			}
+			analysisClient.expectedRealmsQueried = []string{"testproject:realm1", "testproject:realm2"}
+
+			request := &pb.QueryClusterSummariesRequest{
+				Project:       "testproject",
+				FailureFilter: "test_id:\"pita.Boot\" failure_reason:\"failed to boot\"",
+				OrderBy:       "presubmit_rejects desc, critical_failures_exonerated, failures desc",
+			}
+			Convey("Not authorised to list clusters", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermListClusters)
+
+				response, err := server.QueryClusterSummaries(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.list")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to perform expensive queries", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermExpensiveClusterQueries)
+
+				response, err := server.QueryClusterSummaries(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.expensiveQueries")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to get rules", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRule)
+
+				response, err := server.QueryClusterSummaries(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.rules.get")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to list test results in any realm", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
+
+				response, err := server.QueryClusterSummaries(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to list test exonerations in any realm", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestExonerations)
+
+				response, err := server.QueryClusterSummaries(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
+				So(response, ShouldBeNil)
+			})
+			Convey("Valid request", func() {
+				expectedResponse := &pb.QueryClusterSummariesResponse{
+					ClusterSummaries: []*pb.ClusterSummary{
+						{
+							ClusterId: &pb.ClusterId{
+								Algorithm: "rules",
+								Id:        rs[0].RuleID,
+							},
+							Title: rs[0].RuleDefinition,
+							Bug: &pb.AssociatedBug{
+								System:   "monorail",
+								Id:       "chromium/7654321",
+								LinkText: "crbug.com/7654321",
+								Url:      "https://bugs.chromium.org/p/chromium/issues/detail?id=7654321",
+							},
+							PresubmitRejects:           1,
+							CriticalFailuresExonerated: 2,
+							Failures:                   3,
+						},
+						{
+							ClusterId: &pb.ClusterId{
+								Algorithm: "reason-v3",
+								Id:        "cccccc00000000000000000000000001",
+							},
+							Title:                      `Example failure reason 2.`,
+							PresubmitRejects:           4,
+							CriticalFailuresExonerated: 5,
+							Failures:                   6,
+						},
+						{
+							ClusterId: &pb.ClusterId{
+								Algorithm: "rules",
+								Id:        "01234567890abcdef01234567890abcdef",
+							},
+							Title:                      `(rule archived)`,
+							PresubmitRejects:           7,
+							CriticalFailuresExonerated: 8,
+							Failures:                   9,
+						},
+					},
+				}
+
+				Convey("With filters and order by", func() {
+					response, err := server.QueryClusterSummaries(ctx, request)
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, expectedResponse)
+				})
+				Convey("Without filters or order", func() {
+					request.FailureFilter = ""
+					request.OrderBy = ""
+
+					response, err := server.QueryClusterSummaries(ctx, request)
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, expectedResponse)
+				})
+				Convey("Without rule definition get permission", func() {
+					authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRuleDefinition)
+
+					// The RPC cannot return the rule definition as the
+					// cluster title as the user is not authorised to see it.
+					// Instead, it should generate a description of the
+					// content of the cluster based on what the user can see.
+					expectedResponse.ClusterSummaries[0].Title = "Selected failures in TestID 1"
+
+					response, err := server.QueryClusterSummaries(ctx, request)
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, expectedResponse)
+				})
+			})
+			Convey("Invalid request", func() {
+				Convey("Dataset does not exist", func() {
+					delete(analysisClient.clusterMetricsByProject, "testproject")
+
+					// Run
+					response, err := server.QueryClusterSummaries(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCNotFound, "Weetbix BigQuery dataset not provisioned for project or cluster analysis is not yet available")
+				})
+				Convey("Failure filter syntax is invalid", func() {
+					request.FailureFilter = "test_id::"
+
+					// Run
+					response, err := server.QueryClusterSummaries(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "failure_filter: expected arg after :")
+				})
+				Convey("Failure filter references non-existant column", func() {
+					request.FailureFilter = `test:"pita.Boot"`
+
+					// Run
+					response, err := server.QueryClusterSummaries(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, `failure_filter: no filterable field named "test"`)
+				})
+				Convey("Failure filter references unimplemented feature", func() {
+					request.FailureFilter = "test<=\"blah\""
+
+					// Run
+					response, err := server.QueryClusterSummaries(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "failure_filter: comparator operator not implemented yet")
+				})
+				Convey("Order by syntax invalid", func() {
+					request.OrderBy = "presubmit_rejects asc"
+
+					// Run
+					response, err := server.QueryClusterSummaries(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, `order_by: invalid ordering "presubmit_rejects asc"`)
+				})
+				Convey("Order by syntax references invalid column", func() {
+					request.OrderBy = "not_exists desc"
+
+					// Run
+					response, err := server.QueryClusterSummaries(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, `order_by: no sortable field named "not_exists"`)
+				})
+			})
+		})
+		Convey("GetReclusteringProgress", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{{
+				Realm:      "testproject:@root",
+				Permission: perms.PermGetCluster,
+			}}
+
+			request := &pb.GetReclusteringProgressRequest{
+				Name: "projects/testproject/reclusteringProgress",
+			}
+			Convey("Not authorised to get cluster", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
+
+				response, err := server.GetReclusteringProgress(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.get")
+				So(response, ShouldBeNil)
+			})
+			Convey("With a valid request", func() {
+				rulesVersion := time.Date(2021, time.January, 1, 1, 0, 0, 0, time.UTC)
+				reference := time.Date(2020, time.February, 1, 1, 0, 0, 0, time.UTC)
+				configVersion := time.Date(2019, time.March, 1, 1, 0, 0, 0, time.UTC)
+				rns := []*runs.ReclusteringRun{
+					runs.NewRun(0).
+						WithProject("testproject").
+						WithAttemptTimestamp(reference.Add(-5 * time.Minute)).
+						WithRulesVersion(rulesVersion).
+						WithAlgorithmsVersion(2).
+						WithConfigVersion(configVersion).
+						WithNoReportedProgress().
+						Build(),
+					runs.NewRun(1).
+						WithProject("testproject").
+						WithAttemptTimestamp(reference.Add(-10 * time.Minute)).
+						WithRulesVersion(rulesVersion).
+						WithAlgorithmsVersion(2).
+						WithConfigVersion(configVersion).
+						WithReportedProgress(500).
+						Build(),
+					runs.NewRun(2).
+						WithProject("testproject").
+						WithAttemptTimestamp(reference.Add(-20 * time.Minute)).
+						WithRulesVersion(rulesVersion.Add(-1 * time.Hour)).
+						WithAlgorithmsVersion(1).
+						WithConfigVersion(configVersion.Add(-1 * time.Hour)).
+						WithCompletedProgress().
+						Build(),
+				}
+				err := runs.SetRunsForTesting(ctx, rns)
+				So(err, ShouldBeNil)
+
+				// Run
+				response, err := server.GetReclusteringProgress(ctx, request)
+
+				// Verify.
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, &pb.ReclusteringProgress{
+					Name:             "projects/testproject/reclusteringProgress",
+					ProgressPerMille: 500,
+					Last: &pb.ClusteringVersion{
+						AlgorithmsVersion: 1,
+						ConfigVersion:     timestamppb.New(configVersion.Add(-1 * time.Hour)),
+						RulesVersion:      timestamppb.New(rulesVersion.Add(-1 * time.Hour)),
+					},
+					Next: &pb.ClusteringVersion{
+						AlgorithmsVersion: 2,
+						ConfigVersion:     timestamppb.New(configVersion),
+						RulesVersion:      timestamppb.New(rulesVersion),
+					},
+				})
+			})
+			Convey("With an invalid request", func() {
+				Convey("Invalid name", func() {
+					request.Name = "invalid"
+
+					// Run
+					response, err := server.GetReclusteringProgress(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "name: invalid reclustering progress name, expected format: projects/{project}/reclusteringProgress")
+				})
+			})
+		})
+		Convey("QueryClusterFailures", func() {
+			authState.IdentityPermissions = listTestResultsPermissions(
+				"testproject:realm1",
+				"testproject:realm2",
+				"otherproject:realm3",
+			)
+			authState.IdentityPermissions = append(authState.IdentityPermissions, authtest.RealmPermission{
+				Realm:      "testproject:@root",
+				Permission: perms.PermGetCluster,
+			}, authtest.RealmPermission{
+				Realm:      "testproject:@root",
+				Permission: perms.PermExpensiveClusterQueries,
+			})
+
+			request := &pb.QueryClusterFailuresRequest{
+				Parent: "projects/testproject/clusters/reason-v1/cccccc00000000000000000000000001/failures",
+			}
+			Convey("Not authorised to get cluster", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetCluster)
+
+				response, err := server.QueryClusterFailures(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.get")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to perform expensive queries", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermExpensiveClusterQueries)
+
+				response, err := server.QueryClusterFailures(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.clusters.expensiveQueries")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to list test results in any realm", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestResults)
+
+				response, err := server.QueryClusterFailures(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
+				So(response, ShouldBeNil)
+			})
+			Convey("Not authorised to list test exonerations in any realm", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, rdbperms.PermListTestExonerations)
+
+				response, err := server.QueryClusterFailures(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permissions [resultdb.testResults.list resultdb.testExonerations.list] in any realm in project \"testproject\"")
+				So(response, ShouldBeNil)
+			})
+			Convey("With a valid request", func() {
+				analysisClient.expectedRealmsQueried = []string{"testproject:realm1", "testproject:realm2"}
+				analysisClient.failuresByProjectAndCluster["testproject"] = map[clustering.ClusterID][]*analysis.ClusterFailure{
+					{
+						Algorithm: "reason-v1",
+						ID:        "cccccc00000000000000000000000001",
+					}: {
+						{
+							TestID: bqString("testID-1"),
+							Variant: []*analysis.Variant{
+								{
+									Key:   bqString("key1"),
+									Value: bqString("value1"),
+								},
+								{
+									Key:   bqString("key2"),
+									Value: bqString("value2"),
+								},
+							},
+							PresubmitRunID: &analysis.PresubmitRunID{
+								System: bqString("luci-cv"),
+								ID:     bqString("123456789"),
+							},
+							PresubmitRunOwner: bqString("user"),
+							PresubmitRunMode:  bqString(analysis.ToBQPresubmitRunMode(pb.PresubmitRunMode_QUICK_DRY_RUN)),
+							Changelists: []*analysis.Changelist{
+								{
+									Host:     bqString("testproject.googlesource.com"),
+									Change:   bigquery.NullInt64{Int64: 100006, Valid: true},
+									Patchset: bigquery.NullInt64{Int64: 106, Valid: true},
+								},
+								{
+									Host:     bqString("testproject-internal.googlesource.com"),
+									Change:   bigquery.NullInt64{Int64: 100007, Valid: true},
+									Patchset: bigquery.NullInt64{Int64: 107, Valid: true},
+								},
+							},
+							PartitionTime: bigquery.NullTimestamp{Timestamp: time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC), Valid: true},
+							Exonerations: []*analysis.Exoneration{
+								{
+									Reason: bqString(pb.ExonerationReason_OCCURS_ON_MAINLINE.String()),
+								},
+								{
+									Reason: bqString(pb.ExonerationReason_NOT_CRITICAL.String()),
+								},
+							},
+							BuildStatus:                 bqString(analysis.ToBQBuildStatus(pb.BuildStatus_BUILD_STATUS_FAILURE)),
+							IsBuildCritical:             bigquery.NullBool{Bool: true, Valid: true},
+							IngestedInvocationID:        bqString("build-1234567890"),
+							IsIngestedInvocationBlocked: bigquery.NullBool{Bool: true, Valid: true},
+							Count:                       15,
+						},
+						{
+							TestID: bigquery.NullString{StringVal: "testID-2"},
+							Variant: []*analysis.Variant{
+								{
+									Key:   bqString("key1"),
+									Value: bqString("value2"),
+								},
+								{
+									Key:   bqString("key3"),
+									Value: bqString("value3"),
+								},
+							},
+							PresubmitRunID:              nil,
+							PresubmitRunOwner:           bigquery.NullString{},
+							PresubmitRunMode:            bigquery.NullString{},
+							Changelists:                 nil,
+							PartitionTime:               bigquery.NullTimestamp{Timestamp: time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC), Valid: true},
+							BuildStatus:                 bqString(analysis.ToBQBuildStatus(pb.BuildStatus_BUILD_STATUS_CANCELED)),
+							IsBuildCritical:             bigquery.NullBool{},
+							IngestedInvocationID:        bqString("build-9888887771"),
+							IsIngestedInvocationBlocked: bigquery.NullBool{Bool: true, Valid: true},
+							Count:                       1,
+						},
+					},
+				}
+
+				expectedResponse := &pb.QueryClusterFailuresResponse{
+					Failures: []*pb.DistinctClusterFailure{
+						{
+							TestId:        "testID-1",
+							Variant:       pbutil.Variant("key1", "value1", "key2", "value2"),
+							PartitionTime: timestamppb.New(time.Date(2123, time.April, 1, 2, 3, 4, 5, time.UTC)),
+							PresubmitRun: &pb.DistinctClusterFailure_PresubmitRun{
+								PresubmitRunId: &pb.PresubmitRunId{
+									System: "luci-cv",
+									Id:     "123456789",
+								},
+								Owner: "user",
+								Mode:  pb.PresubmitRunMode_QUICK_DRY_RUN,
+							},
+							IsBuildCritical: true,
+							Exonerations: []*pb.DistinctClusterFailure_Exoneration{{
+								Reason: pb.ExonerationReason_OCCURS_ON_MAINLINE,
+							}, {
+								Reason: pb.ExonerationReason_NOT_CRITICAL,
+							}},
+							BuildStatus:                 pb.BuildStatus_BUILD_STATUS_FAILURE,
+							IngestedInvocationId:        "build-1234567890",
+							IsIngestedInvocationBlocked: true,
+							Changelists: []*pb.Changelist{
+								{
+									Host:     "testproject.googlesource.com",
+									Change:   100006,
+									Patchset: 106,
+								},
+								{
+									Host:     "testproject-internal.googlesource.com",
+									Change:   100007,
+									Patchset: 107,
+								},
+							},
+							Count: 15,
+						},
+						{
+							TestId:                      "testID-2",
+							Variant:                     pbutil.Variant("key1", "value2", "key3", "value3"),
+							PartitionTime:               timestamppb.New(time.Date(2124, time.May, 2, 3, 4, 5, 6, time.UTC)),
+							PresubmitRun:                nil,
+							IsBuildCritical:             false,
+							Exonerations:                nil,
+							BuildStatus:                 pb.BuildStatus_BUILD_STATUS_CANCELED,
+							IngestedInvocationId:        "build-9888887771",
+							IsIngestedInvocationBlocked: true,
+							Count:                       1,
+						},
+					},
+				}
+
+				// Run
+				response, err := server.QueryClusterFailures(ctx, request)
+
+				// Verify.
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, expectedResponse)
+			})
+			Convey("With an invalid request", func() {
+				Convey("Invalid parent", func() {
+					request.Parent = "blah"
+
+					// Run
+					response, err := server.QueryClusterFailures(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "parent: invalid cluster failures name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}/failures")
+				})
+				Convey("Invalid cluster algorithm in parent", func() {
+					request.Parent = "projects/blah/clusters/reason/cccccc00000000000000000000000001/failures"
+
+					// Run
+					response, err := server.QueryClusterFailures(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "parent: invalid cluster identity: algorithm not valid")
+				})
+				Convey("Invalid cluster ID in parent", func() {
+					request.Parent = "projects/blah/clusters/reason-v3/123/failures"
+
+					// Run
+					response, err := server.QueryClusterFailures(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "parent: invalid cluster identity: ID is not valid lowercase hexadecimal bytes")
+				})
+				Convey("Dataset does not exist", func() {
+					delete(analysisClient.clustersByProject, "testproject")
+
+					// Run
+					response, err := server.QueryClusterFailures(ctx, request)
+
+					// Verify
+					So(response, ShouldBeNil)
+					So(err, ShouldBeRPCNotFound, "Weetbix BigQuery dataset not provisioned for project or clustered failures not yet available")
+				})
+			})
+		})
+	})
+}
+
+func bqString(value string) bigquery.NullString {
+	return bigquery.NullString{StringVal: value, Valid: true}
+}
+
+func listTestResultsPermissions(realms ...string) []authtest.RealmPermission {
+	var result []authtest.RealmPermission
+	for _, r := range realms {
+		result = append(result, authtest.RealmPermission{
+			Realm:      r,
+			Permission: rdbperms.PermListTestResults,
+		})
+		result = append(result, authtest.RealmPermission{
+			Realm:      r,
+			Permission: rdbperms.PermListTestExonerations,
+		})
+	}
+	return result
+}
+
+func removePermission(perms []authtest.RealmPermission, permission realms.Permission) []authtest.RealmPermission {
+	var result []authtest.RealmPermission
+	for _, p := range perms {
+		if p.Permission != permission {
+			result = append(result, p)
+		}
+	}
+	return result
+}
+
+func emptyMetricValues() *pb.Cluster_MetricValues {
+	return &pb.Cluster_MetricValues{
+		OneDay:   &pb.Cluster_MetricValues_Counts{},
+		ThreeDay: &pb.Cluster_MetricValues_Counts{},
+		SevenDay: &pb.Cluster_MetricValues_Counts{},
+	}
+}
+
+func failureReasonClusterEntry(projectcfg *compiledcfg.ProjectConfig, primaryErrorMessage string) *pb.ClusterResponse_ClusteredTestResult_ClusterEntry {
+	alg := &failurereason.Algorithm{}
+	clusterID := alg.Cluster(projectcfg, &clustering.Failure{
+		Reason: &pb.FailureReason{
+			PrimaryErrorMessage: primaryErrorMessage,
+		},
+	})
+	return &pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
+		ClusterId: &pb.ClusterId{
+			Algorithm: failurereason.AlgorithmName,
+			Id:        hex.EncodeToString(clusterID),
+		},
+	}
+}
+
+func testNameClusterEntry(projectcfg *compiledcfg.ProjectConfig, testID string) *pb.ClusterResponse_ClusteredTestResult_ClusterEntry {
+	alg := &testname.Algorithm{}
+	clusterID := alg.Cluster(projectcfg, &clustering.Failure{
+		TestID: testID,
+	})
+	return &pb.ClusterResponse_ClusteredTestResult_ClusterEntry{
+		ClusterId: &pb.ClusterId{
+			Algorithm: testname.AlgorithmName,
+			Id:        hex.EncodeToString(clusterID),
+		},
+	}
+}
+
+// sortClusterEntries sorts clusters by ascending Cluster ID.
+func sortClusterEntries(entries []*pb.ClusterResponse_ClusteredTestResult_ClusterEntry) []*pb.ClusterResponse_ClusteredTestResult_ClusterEntry {
+	result := make([]*pb.ClusterResponse_ClusteredTestResult_ClusterEntry, len(entries))
+	copy(result, entries)
+	sort.Slice(result, func(i, j int) bool {
+		if result[i].ClusterId.Algorithm != result[j].ClusterId.Algorithm {
+			return result[i].ClusterId.Algorithm < result[j].ClusterId.Algorithm
+		}
+		return result[i].ClusterId.Id < result[j].ClusterId.Id
+	})
+	return result
+}
+
+type fakeAnalysisClient struct {
+	clustersByProject           map[string][]*analysis.Cluster
+	failuresByProjectAndCluster map[string]map[clustering.ClusterID][]*analysis.ClusterFailure
+	clusterMetricsByProject     map[string][]*analysis.ClusterSummary
+	expectedRealmsQueried       []string
+}
+
+func newFakeAnalysisClient() *fakeAnalysisClient {
+	return &fakeAnalysisClient{
+		clustersByProject:           make(map[string][]*analysis.Cluster),
+		failuresByProjectAndCluster: make(map[string]map[clustering.ClusterID][]*analysis.ClusterFailure),
+		clusterMetricsByProject:     make(map[string][]*analysis.ClusterSummary),
+	}
+}
+
+func (f *fakeAnalysisClient) ReadClusters(ctx context.Context, project string, clusterIDs []clustering.ClusterID) ([]*analysis.Cluster, error) {
+	clusters, ok := f.clustersByProject[project]
+	if !ok {
+		return nil, analysis.ProjectNotExistsErr
+	}
+
+	var results []*analysis.Cluster
+	for _, c := range clusters {
+		include := false
+		for _, ci := range clusterIDs {
+			if ci == c.ClusterID {
+				include = true
+			}
+		}
+		if include {
+			results = append(results, c)
+		}
+	}
+	return results, nil
+}
+
+func (f *fakeAnalysisClient) QueryClusterSummaries(ctx context.Context, project string, options *analysis.QueryClusterSummariesOptions) ([]*analysis.ClusterSummary, error) {
+	clusters, ok := f.clusterMetricsByProject[project]
+	if !ok {
+		return nil, analysis.ProjectNotExistsErr
+	}
+
+	set := stringset.NewFromSlice(options.Realms...)
+	if set.Len() != len(f.expectedRealmsQueried) || !set.HasAll(f.expectedRealmsQueried...) {
+		panic("realms passed to QueryClusterSummaries do not match expected")
+	}
+
+	_, _, err := analysis.ClusteredFailuresTable.WhereClause(options.FailureFilter, "w_")
+	if err != nil {
+		return nil, analysis.InvalidArgumentTag.Apply(errors.Annotate(err, "failure_filter").Err())
+	}
+	_, err = analysis.ClusterSummariesTable.OrderByClause(options.OrderBy)
+	if err != nil {
+		return nil, analysis.InvalidArgumentTag.Apply(errors.Annotate(err, "order_by").Err())
+	}
+
+	var results []*analysis.ClusterSummary
+	for _, c := range clusters {
+		results = append(results, c)
+	}
+	return results, nil
+}
+
+func (f *fakeAnalysisClient) ReadClusterFailures(ctx context.Context, options analysis.ReadClusterFailuresOptions) ([]*analysis.ClusterFailure, error) {
+	failuresByCluster, ok := f.failuresByProjectAndCluster[options.Project]
+	if !ok {
+		return nil, analysis.ProjectNotExistsErr
+	}
+
+	set := stringset.NewFromSlice(options.Realms...)
+	if set.Len() != len(f.expectedRealmsQueried) || !set.HasAll(f.expectedRealmsQueried...) {
+		panic("realms passed to ReadClusterFailures do not match expected")
+	}
+
+	return failuresByCluster[options.ClusterID], nil
+}
diff --git a/analysis/rpc/decorator.go b/analysis/rpc/decorator.go
new file mode 100644
index 0000000..2047da3
--- /dev/null
+++ b/analysis/rpc/decorator.go
@@ -0,0 +1,51 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+
+	"github.com/golang/protobuf/proto"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/grpc/appstatus"
+	"go.chromium.org/luci/server/auth"
+	"google.golang.org/grpc/codes"
+)
+
+const allowGroup = "weetbix-access"
+
+// Checks if this call is allowed, returns an error if it is.
+func checkAllowedPrelude(ctx context.Context, methodName string, req proto.Message) (context.Context, error) {
+	if err := checkAllowed(ctx); err != nil {
+		return ctx, err
+	}
+	return ctx, nil
+}
+
+// Logs and converts the errors to GRPC type errors.
+func gRPCifyAndLogPostlude(ctx context.Context, methodName string, rsp proto.Message, err error) error {
+	return appstatus.GRPCifyAndLog(ctx, err)
+}
+
+func checkAllowed(ctx context.Context) error {
+	switch yes, err := auth.IsMember(ctx, allowGroup); {
+	case err != nil:
+		return errors.Annotate(err, "failed to check ACL").Err()
+	case !yes:
+		return appstatus.Errorf(codes.PermissionDenied, "not a member of %s", allowGroup)
+	default:
+		return nil
+	}
+}
diff --git a/analysis/rpc/errors.go b/analysis/rpc/errors.go
new file mode 100644
index 0000000..df4e0ba
--- /dev/null
+++ b/analysis/rpc/errors.go
@@ -0,0 +1,39 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"go.chromium.org/luci/grpc/appstatus"
+	"google.golang.org/grpc/codes"
+)
+
+// invalidArgumentError annotates err as having an invalid argument.
+// The error message is shared with the requester as is.
+//
+// Note that this differs from FailedPrecondition. It indicates arguments
+// that are problematic regardless of the state of the system
+// (e.g., a malformed file name).
+func invalidArgumentError(err error) error {
+	return appstatus.Attachf(err, codes.InvalidArgument, "%s", err)
+}
+
+// failedPreconditionError annotates err as failing a predondition for the
+// operation. The error message is shared with the requester as is.
+//
+// See codes.FailedPrecondition for more context about when this
+// should be used compared to invalid argument.
+func failedPreconditionError(err error) error {
+	return appstatus.Attachf(err, codes.FailedPrecondition, "%s", err)
+}
diff --git a/analysis/rpc/init_data.go b/analysis/rpc/init_data.go
new file mode 100644
index 0000000..6dd660e
--- /dev/null
+++ b/analysis/rpc/init_data.go
@@ -0,0 +1,63 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+
+	"go.chromium.org/luci/server/auth"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// A server that provides the data to initialize the client.
+type initDataGeneratorServer struct{}
+
+// Creates a new initialization data server.
+func NewInitDataGeneratorServer() *pb.DecoratedInitDataGenerator {
+	return &pb.DecoratedInitDataGenerator{
+		Prelude:  checkAllowedPrelude,
+		Service:  &initDataGeneratorServer{},
+		Postlude: gRPCifyAndLogPostlude,
+	}
+}
+
+// Gets the initialization data.
+func (*initDataGeneratorServer) GenerateInitData(ctx context.Context, request *pb.GenerateInitDataRequest) (*pb.GenerateInitDataResponse, error) {
+	logoutURL, err := auth.LogoutURL(ctx, request.ReferrerUrl)
+	if err != nil {
+		return nil, err
+	}
+
+	config, err := config.Get(ctx)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.GenerateInitDataResponse{
+		InitData: &pb.InitData{
+			Hostnames: &pb.Hostnames{
+				MonorailHostname: config.MonorailHostname,
+			},
+			User: &pb.User{
+				Email: auth.CurrentUser(ctx).Email,
+			},
+			AuthUrls: &pb.AuthUrls{
+				LogoutUrl: logoutURL,
+			},
+		},
+	}, nil
+}
diff --git a/analysis/rpc/init_data_test.go b/analysis/rpc/init_data_test.go
new file mode 100644
index 0000000..0429a27
--- /dev/null
+++ b/analysis/rpc/init_data_test.go
@@ -0,0 +1,101 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/secrets"
+	"go.chromium.org/luci/server/secrets/testsecrets"
+	"google.golang.org/grpc/codes"
+	grpcStatus "google.golang.org/grpc/status"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestInitData(t *testing.T) {
+	Convey("Given an init data server", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		// For user identification.
+		ctx = authtest.MockAuthConfig(ctx)
+		ctx = auth.WithState(ctx, &authtest.FakeState{
+			Identity:       "user:someone@example.com",
+			IdentityGroups: []string{"weetbix-access"},
+		})
+		ctx = secrets.Use(ctx, &testsecrets.Store{})
+
+		// Provides datastore implementation needed for project config.
+		ctx = memory.Use(ctx)
+
+		server := NewInitDataGeneratorServer()
+		cfg, err := config.CreatePlaceholderConfig()
+		So(err, ShouldBeNil)
+
+		config.SetTestConfig(ctx, cfg)
+
+		Convey("Unauthorised requests are rejected", func() {
+			ctx = auth.WithState(ctx, &authtest.FakeState{
+				Identity: "user:someone@example.com",
+				// Not a member of weetbix-access.
+				IdentityGroups: []string{"other-group"},
+			})
+
+			// Make some request (the request should not matter, as
+			// a common decorator is used for all requests.)
+			request := &pb.GenerateInitDataRequest{
+				ReferrerUrl: "/p/chromium",
+			}
+
+			rule, err := server.GenerateInitData(ctx, request)
+			st, _ := grpcStatus.FromError(err)
+			So(st.Code(), ShouldEqual, codes.PermissionDenied)
+			So(st.Message(), ShouldEqual, "not a member of weetbix-access")
+			So(rule, ShouldBeNil)
+		})
+		Convey("When getting data", func() {
+			request := &pb.GenerateInitDataRequest{
+				ReferrerUrl: "/p/chromium",
+			}
+
+			result, err := server.GenerateInitData(ctx, request)
+
+			So(err, ShouldBeNil)
+
+			expected := &pb.GenerateInitDataResponse{
+				InitData: &pb.InitData{
+					Hostnames: &pb.Hostnames{
+						MonorailHostname: "monorail-test.appspot.com",
+					},
+					User: &pb.User{
+						Email: "someone@example.com",
+					},
+					AuthUrls: &pb.AuthUrls{
+						LogoutUrl: "http://fake.example.com/logout?dest=%2Fp%2Fchromium",
+					},
+				},
+			}
+
+			So(result, ShouldResembleProto, expected)
+		})
+	})
+}
diff --git a/analysis/rpc/main_test.go b/analysis/rpc/main_test.go
new file mode 100644
index 0000000..7ed6237
--- /dev/null
+++ b/analysis/rpc/main_test.go
@@ -0,0 +1,27 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"testing"
+
+	"go.chromium.org/luci/analysis/internal/testutil"
+)
+
+const testProject = "testproject"
+
+func TestMain(m *testing.M) {
+	testutil.SpannerTestMain(m)
+}
diff --git a/analysis/rpc/names.go b/analysis/rpc/names.go
new file mode 100644
index 0000000..db5fe58
--- /dev/null
+++ b/analysis/rpc/names.go
@@ -0,0 +1,118 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"fmt"
+	"regexp"
+
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+)
+
+// Regular expressions for matching resource names used in APIs.
+var (
+	GenericKeyPattern = "[a-z0-9\\-]+"
+	RuleNameRe        = regexp.MustCompile(`^projects/(` + config.ProjectRePattern + `)/rules/(` + rules.RuleIDRePattern + `)$`)
+	// ClusterNameRe performs partial validation of a cluster resource name.
+	// Cluster algorithm and ID must be further validated by
+	// ClusterID.Validate().
+	ClusterNameRe = regexp.MustCompile(`^projects/(` + config.ProjectRePattern + `)/clusters/(` + GenericKeyPattern + `)/(` + GenericKeyPattern + `)$`)
+	// ClusterFailuresNameRe performs a partial validation of the resource
+	// name for a cluster's failures.
+	// Cluster algorithm and ID must be further validated by
+	// ClusterID.Validate().
+	ClusterFailuresNameRe      = regexp.MustCompile(`^projects/(` + config.ProjectRePattern + `)/clusters/(` + GenericKeyPattern + `)/(` + GenericKeyPattern + `)/failures$`)
+	ProjectNameRe              = regexp.MustCompile(`^projects/(` + config.ProjectRePattern + `)$`)
+	ProjectConfigNameRe        = regexp.MustCompile(`^projects/(` + config.ProjectRePattern + `)/config$`)
+	ReclusteringProgressNameRe = regexp.MustCompile(`^projects/(` + config.ProjectRePattern + `)/reclusteringProgress$`)
+)
+
+// parseRuleName parses a rule resource name into its constituent ID parts.
+func parseRuleName(name string) (project, ruleID string, err error) {
+	match := RuleNameRe.FindStringSubmatch(name)
+	if match == nil {
+		return "", "", errors.New("invalid rule name, expected format: projects/{project}/rules/{rule_id}")
+	}
+	return match[1], match[2], nil
+}
+
+// parseProjectName parses a project resource name into a project ID.
+func parseProjectName(name string) (project string, err error) {
+	match := ProjectNameRe.FindStringSubmatch(name)
+	if match == nil {
+		return "", errors.New("invalid project name, expected format: projects/{project}")
+	}
+	return match[1], nil
+}
+
+// parseProjectConfigName parses a project config resource name into a project ID.
+func parseProjectConfigName(name string) (project string, err error) {
+	match := ProjectConfigNameRe.FindStringSubmatch(name)
+	if match == nil {
+		return "", errors.New("invalid project config name, expected format: projects/{project}/config")
+	}
+	return match[1], nil
+}
+
+// parseReclusteringProgressName parses a reclustering progress resource name
+// into its constituent project ID part.
+func parseReclusteringProgressName(name string) (project string, err error) {
+	match := ReclusteringProgressNameRe.FindStringSubmatch(name)
+	if match == nil {
+		return "", errors.New("invalid reclustering progress name, expected format: projects/{project}/reclusteringProgress")
+	}
+	return match[1], nil
+}
+
+// parseClusterName parses a cluster resource name into its constituent ID
+// parts. Algorithm aliases are resolved to concrete algorithm names.
+func parseClusterName(name string) (project string, clusterID clustering.ClusterID, err error) {
+	match := ClusterNameRe.FindStringSubmatch(name)
+	if match == nil {
+		return "", clustering.ClusterID{}, errors.New("invalid cluster name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}")
+	}
+	algorithm := resolveAlgorithm(match[2])
+	id := match[3]
+	cID := clustering.ClusterID{Algorithm: algorithm, ID: id}
+	if err := cID.Validate(); err != nil {
+		return "", clustering.ClusterID{}, errors.Annotate(err, "invalid cluster identity").Err()
+	}
+	return match[1], cID, nil
+}
+
+// parseClusterFailuresName parses the resource name for a cluster's failures
+// into its constituent ID parts. Algorithm aliases are resolved to
+// concrete algorithm names.
+func parseClusterFailuresName(name string) (project string, clusterID clustering.ClusterID, err error) {
+	match := ClusterFailuresNameRe.FindStringSubmatch(name)
+	if match == nil {
+		return "", clustering.ClusterID{}, errors.New("invalid cluster failures name, expected format: projects/{project}/clusters/{cluster_alg}/{cluster_id}/failures")
+	}
+	algorithm := resolveAlgorithm(match[2])
+	id := match[3]
+	cID := clustering.ClusterID{Algorithm: algorithm, ID: id}
+	if err := cID.Validate(); err != nil {
+		return "", clustering.ClusterID{}, errors.Annotate(err, "invalid cluster identity").Err()
+	}
+	return match[1], cID, nil
+}
+
+func ruleName(project, ruleID string) string {
+	return fmt.Sprintf("projects/%s/rules/%s", project, ruleID)
+}
diff --git a/analysis/rpc/project_config.go b/analysis/rpc/project_config.go
new file mode 100644
index 0000000..c65ac9b
--- /dev/null
+++ b/analysis/rpc/project_config.go
@@ -0,0 +1,40 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"time"
+
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+)
+
+// readProjectConfig reads project config. This is intended for use in
+// top-level RPC handlers. The caller should directly return any errors
+// returned as the error of the RPC; the returned errors have been
+// properly annotated with an appstatus.
+func readProjectConfig(ctx context.Context, project string) (*compiledcfg.ProjectConfig, error) {
+	cfg, err := compiledcfg.Project(ctx, project, time.Time{})
+	if err != nil {
+		if err == compiledcfg.NotExistsErr {
+			return nil, failedPreconditionError(errors.New("project does not exist in Weetbix"))
+		}
+		// GRPCifyAndLog will log this, and report an internal error to the caller.
+		return nil, errors.Annotate(err, "obtain project config").Err()
+	}
+	return cfg, nil
+}
diff --git a/analysis/rpc/projects.go b/analysis/rpc/projects.go
new file mode 100644
index 0000000..cbf0c98
--- /dev/null
+++ b/analysis/rpc/projects.go
@@ -0,0 +1,102 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"fmt"
+	"sort"
+	"strings"
+
+	"go.chromium.org/luci/common/errors"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/perms"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+type projectServer struct{}
+
+func NewProjectsServer() *pb.DecoratedProjects {
+	return &pb.DecoratedProjects{
+		Prelude:  checkAllowedPrelude,
+		Service:  &projectServer{},
+		Postlude: gRPCifyAndLogPostlude,
+	}
+}
+
+func (*projectServer) GetConfig(ctx context.Context, req *pb.GetProjectConfigRequest) (*pb.ProjectConfig, error) {
+	project, err := parseProjectConfigName(req.Name)
+	if err != nil {
+		return nil, invalidArgumentError(errors.Annotate(err, "name").Err())
+	}
+
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermGetConfig); err != nil {
+		return nil, err
+	}
+
+	// Fetch a recent project configuration.
+	// (May be a recent value that was cached.)
+	cfg, err := readProjectConfig(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	response := &pb.ProjectConfig{
+		Name: fmt.Sprintf("projects/%s/config", project),
+		Monorail: &pb.ProjectConfig_Monorail{
+			Project:       cfg.Config.Monorail.Project,
+			DisplayPrefix: cfg.Config.Monorail.DisplayPrefix,
+		},
+	}
+	return response, nil
+}
+
+func (*projectServer) List(ctx context.Context, request *pb.ListProjectsRequest) (*pb.ListProjectsResponse, error) {
+	projects, err := config.Projects(ctx)
+	if err != nil {
+		return nil, errors.Annotate(err, "fetching project configs").Err()
+	}
+
+	readableProjects := make([]string, 0, len(projects))
+	for project := range projects {
+		hasAccess, err := perms.HasProjectPermission(ctx, project, perms.PermGetConfig)
+		if err != nil {
+			return nil, err
+		}
+		if hasAccess {
+			readableProjects = append(readableProjects, project)
+		}
+	}
+
+	// Return projects in a stable order.
+	sort.Strings(readableProjects)
+
+	return &pb.ListProjectsResponse{
+		Projects: createProjectPbs(readableProjects),
+	}, nil
+}
+
+func createProjectPbs(projects []string) []*pb.Project {
+	projectsPbs := make([]*pb.Project, 0, len(projects))
+	for _, project := range projects {
+		projectsPbs = append(projectsPbs, &pb.Project{
+			Name:        fmt.Sprintf("projects/%s", project),
+			DisplayName: strings.Title(project),
+			Project:     project,
+		})
+	}
+	return projectsPbs
+}
diff --git a/analysis/rpc/projects_test.go b/analysis/rpc/projects_test.go
new file mode 100644
index 0000000..ba573ff8
--- /dev/null
+++ b/analysis/rpc/projects_test.go
@@ -0,0 +1,189 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/secrets"
+	"go.chromium.org/luci/server/secrets/testsecrets"
+	"google.golang.org/grpc/codes"
+	grpcStatus "google.golang.org/grpc/status"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/perms"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestProjects(t *testing.T) {
+	Convey("Given a projects server", t, func() {
+		ctx := context.Background()
+
+		// For user identification.
+		ctx = authtest.MockAuthConfig(ctx)
+		authState := &authtest.FakeState{
+			Identity:       "user:someone@example.com",
+			IdentityGroups: []string{"weetbix-access"},
+		}
+		ctx = auth.WithState(ctx, authState)
+		ctx = secrets.Use(ctx, &testsecrets.Store{})
+
+		// Provides datastore implementation needed for project config.
+		ctx = memory.Use(ctx)
+		server := NewProjectsServer()
+
+		Convey("Unauthorised requests are rejected", func() {
+			ctx = auth.WithState(ctx, &authtest.FakeState{
+				Identity: "user:someone@example.com",
+				// Not a member of weetbix-access.
+				IdentityGroups: []string{"other-group"},
+			})
+
+			// Make some request (the request should not matter, as
+			// a common decorator is used for all requests.)
+			request := &pb.ListProjectsRequest{}
+
+			rule, err := server.List(ctx, request)
+			st, _ := grpcStatus.FromError(err)
+			So(st.Code(), ShouldEqual, codes.PermissionDenied)
+			So(st.Message(), ShouldEqual, "not a member of weetbix-access")
+			So(rule, ShouldBeNil)
+		})
+		Convey("GetConfig", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetConfig,
+				},
+			}
+
+			// Setup.
+			configs := make(map[string]*configpb.ProjectConfig)
+			projectTest := config.CreatePlaceholderProjectConfig()
+			projectTest.Monorail.Project = "monorailproject"
+			projectTest.Monorail.DisplayPrefix = "displayprefix.com"
+			configs["testproject"] = projectTest
+			config.SetTestProjectConfig(ctx, configs)
+
+			request := &pb.GetProjectConfigRequest{
+				Name: "projects/testproject/config",
+			}
+
+			Convey("No permission to get project config", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetConfig)
+
+				response, err := server.GetConfig(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.config.get")
+				So(response, ShouldBeNil)
+			})
+			Convey("Valid request", func() {
+				response, err := server.GetConfig(ctx, request)
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, &pb.ProjectConfig{
+					Name: "projects/testproject/config",
+					Monorail: &pb.ProjectConfig_Monorail{
+						Project:       "monorailproject",
+						DisplayPrefix: "displayprefix.com",
+					},
+				})
+			})
+			Convey("Invalid request", func() {
+				request.Name = "blah"
+
+				// Run
+				response, err := server.GetConfig(ctx, request)
+
+				// Verify
+				So(err, ShouldBeRPCInvalidArgument, "name: invalid project config name, expected format: projects/{project}/config")
+				So(response, ShouldBeNil)
+			})
+			Convey("With project not configured", func() {
+				err := config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
+				So(err, ShouldBeNil)
+
+				// Run
+				response, err := server.GetConfig(ctx, request)
+
+				// Verify
+				So(err, ShouldBeRPCFailedPrecondition, "project does not exist in Weetbix")
+				So(response, ShouldBeNil)
+			})
+		})
+		Convey("List", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "chromium:@root",
+					Permission: perms.PermGetConfig,
+				},
+				{
+					Realm:      "chrome:@root",
+					Permission: perms.PermGetConfig,
+				},
+			}
+
+			// Setup
+			projectChromium := config.CreatePlaceholderProjectConfig()
+			projectChrome := config.CreatePlaceholderProjectConfig()
+			projectSecret := config.CreatePlaceholderProjectConfig()
+
+			configs := make(map[string]*configpb.ProjectConfig)
+			configs["chromium"] = projectChromium
+			configs["chrome"] = projectChrome
+			configs["secret"] = projectSecret
+			config.SetTestProjectConfig(ctx, configs)
+
+			request := &pb.ListProjectsRequest{}
+
+			Convey("No permission to view any project", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetConfig)
+
+				// Run
+				projectsResponse, err := server.List(ctx, request)
+
+				// Verify
+				So(err, ShouldBeNil)
+				expected := &pb.ListProjectsResponse{Projects: []*pb.Project{}}
+				So(projectsResponse, ShouldResembleProto, expected)
+			})
+			Convey("Valid request", func() {
+				// Run
+				projectsResponse, err := server.List(ctx, request)
+
+				// Verify
+				So(err, ShouldBeNil)
+				expected := &pb.ListProjectsResponse{Projects: []*pb.Project{
+					{
+						Name:        "projects/chrome",
+						DisplayName: "Chrome",
+						Project:     "chrome",
+					},
+					{
+						Name:        "projects/chromium",
+						DisplayName: "Chromium",
+						Project:     "chromium",
+					},
+				}}
+				So(projectsResponse, ShouldResembleProto, expected)
+			})
+		})
+	})
+}
diff --git a/analysis/rpc/rules.go b/analysis/rpc/rules.go
new file mode 100644
index 0000000..e2672f0
--- /dev/null
+++ b/analysis/rpc/rules.go
@@ -0,0 +1,432 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/common/logging"
+	"go.chromium.org/luci/grpc/appstatus"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config/compiledcfg"
+	"go.chromium.org/luci/analysis/internal/perms"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+// Rules implements pb.RulesServer.
+type rulesServer struct {
+}
+
+// NewRulesSever returns a new pb.RulesServer.
+func NewRulesSever() pb.RulesServer {
+	return &pb.DecoratedRules{
+		Prelude:  checkAllowedPrelude,
+		Service:  &rulesServer{},
+		Postlude: gRPCifyAndLogPostlude,
+	}
+}
+
+// Retrieves a rule.
+func (*rulesServer) Get(ctx context.Context, req *pb.GetRuleRequest) (*pb.Rule, error) {
+	project, ruleID, err := parseRuleName(req.Name)
+	if err != nil {
+		return nil, invalidArgumentError(err)
+	}
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermGetRule); err != nil {
+		return nil, err
+	}
+	canSeeDefinition, err := perms.HasProjectPermission(ctx, project, perms.PermGetRuleDefinition)
+	if err != nil {
+		return nil, err
+	}
+
+	cfg, err := readProjectConfig(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	r, err := rules.Read(span.Single(ctx), project, ruleID)
+	if err != nil {
+		if err == rules.NotExistsErr {
+			return nil, appstatus.Error(codes.NotFound, "rule does not exist")
+		}
+		// This will result in an internal error being reported to the caller.
+		return nil, errors.Annotate(err, "reading rule %s", ruleID).Err()
+	}
+	return createRulePB(r, cfg.Config, canSeeDefinition), nil
+}
+
+// Lists rules.
+func (*rulesServer) List(ctx context.Context, req *pb.ListRulesRequest) (*pb.ListRulesResponse, error) {
+	project, err := parseProjectName(req.Parent)
+	if err != nil {
+		return nil, invalidArgumentError(err)
+	}
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermListRules); err != nil {
+		return nil, err
+	}
+	canSeeDefinition, err := perms.HasProjectPermission(ctx, project, perms.PermGetRuleDefinition)
+	if err != nil {
+		return nil, err
+	}
+
+	cfg, err := readProjectConfig(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	// TODO: Update to read all rules (not just active), and implement pagination.
+	rs, err := rules.ReadActive(span.Single(ctx), project)
+	if err != nil {
+		// GRPCifyAndLog will log this, and report an internal error.
+		return nil, errors.Annotate(err, "reading rules").Err()
+	}
+
+	rpbs := make([]*pb.Rule, 0, len(rs))
+	for _, r := range rs {
+		rpbs = append(rpbs, createRulePB(r, cfg.Config, canSeeDefinition))
+	}
+	response := &pb.ListRulesResponse{
+		Rules: rpbs,
+	}
+	return response, nil
+}
+
+// Creates a new rule.
+func (*rulesServer) Create(ctx context.Context, req *pb.CreateRuleRequest) (*pb.Rule, error) {
+	project, err := parseProjectName(req.Parent)
+	if err != nil {
+		return nil, invalidArgumentError(err)
+	}
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermCreateRule); err != nil {
+		return nil, err
+	}
+
+	cfg, err := readProjectConfig(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	ruleID, err := rules.GenerateID()
+	if err != nil {
+		return nil, errors.Annotate(err, "generating Rule ID").Err()
+	}
+	user := auth.CurrentUser(ctx).Email
+
+	r := &rules.FailureAssociationRule{
+		Project:        project,
+		RuleID:         ruleID,
+		RuleDefinition: req.Rule.GetRuleDefinition(),
+		BugID: bugs.BugID{
+			System: req.Rule.Bug.GetSystem(),
+			ID:     req.Rule.Bug.GetId(),
+		},
+		IsActive:      req.Rule.GetIsActive(),
+		IsManagingBug: req.Rule.GetIsManagingBug(),
+		SourceCluster: clustering.ClusterID{
+			Algorithm: req.Rule.SourceCluster.GetAlgorithm(),
+			ID:        req.Rule.SourceCluster.GetId(),
+		},
+	}
+
+	if err := validateBugAgainstConfig(cfg, r.BugID); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+
+	commitTime, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+		// Verify the bug is not used by another rule in this project.
+		bugRules, err := rules.ReadByBug(ctx, r.BugID)
+		if err != nil {
+			return err
+		}
+		for _, otherRule := range bugRules {
+			if otherRule.IsManagingBug {
+				// Avoid conflicts by silently making the bug not managed
+				// by this rule if there is another rule managing it.
+				// Note: this validation implicitly discloses the existence
+				// of rules in projects other than those the user may have
+				// access to.
+				r.IsManagingBug = false
+			}
+			if otherRule.Project == r.Project {
+				return invalidArgumentError(fmt.Errorf("bug already used by a rule in the same project (%s/%s)", otherRule.Project, otherRule.RuleID))
+			}
+		}
+
+		err = rules.Create(ctx, r, user)
+		if err != nil {
+			return invalidArgumentError(err)
+		}
+		return nil
+	})
+	if err != nil {
+		return nil, err
+	}
+	r.CreationTime = commitTime.In(time.UTC)
+	r.CreationUser = user
+	r.LastUpdated = commitTime.In(time.UTC)
+	r.LastUpdatedUser = user
+	r.PredicateLastUpdated = commitTime.In(time.UTC)
+
+	// Log rule changes to provide a way of recovering old system state
+	// if malicious or unintended updates occur.
+	logRuleCreate(ctx, r)
+
+	canSeeDefinition := true
+	return createRulePB(r, cfg.Config, canSeeDefinition), nil
+}
+
+func logRuleCreate(ctx context.Context, rule *rules.FailureAssociationRule) {
+	logging.Infof(ctx, "Rule created (%s/%s): %s", rule.Project, rule.RuleID, formatRule(rule))
+}
+
+// Updates a rule.
+func (*rulesServer) Update(ctx context.Context, req *pb.UpdateRuleRequest) (*pb.Rule, error) {
+	project, ruleID, err := parseRuleName(req.Rule.GetName())
+	if err != nil {
+		return nil, invalidArgumentError(err)
+	}
+	if err := perms.VerifyProjectPermissions(ctx, project, perms.PermUpdateRule); err != nil {
+		return nil, err
+	}
+
+	cfg, err := readProjectConfig(ctx, project)
+	if err != nil {
+		return nil, err
+	}
+
+	user := auth.CurrentUser(ctx).Email
+
+	var predicateUpdated bool
+	var originalRule *rules.FailureAssociationRule
+	var updatedRule *rules.FailureAssociationRule
+	f := func(ctx context.Context) error {
+		rule, err := rules.Read(ctx, project, ruleID)
+		if err != nil {
+			if err == rules.NotExistsErr {
+				return appstatus.Error(codes.NotFound, "rule does not exist")
+			}
+			// This will result in an internal error being reported to the
+			// caller.
+			return errors.Annotate(err, "read rule").Err()
+		}
+		originalRule = &rules.FailureAssociationRule{}
+		*originalRule = *rule
+
+		canSeeDefinition := true
+		if req.Etag != "" && ruleETag(rule, canSeeDefinition) != req.Etag {
+			// Attach a codes.Aborted appstatus to a vanilla error to avoid
+			// ReadWriteTransaction interpreting this case for a scenario
+			// in which it should retry the transaction.
+			err := errors.New("etag mismatch")
+			return appstatus.Attach(err, status.New(codes.Aborted, "the rule was modified since it was last read; the update was not applied."))
+		}
+		updatePredicate := false
+		updatingBug := false
+		updatingManaged := false
+		for _, path := range req.UpdateMask.Paths {
+			// Only limited fields may be modified by the client.
+			switch path {
+			case "rule_definition":
+				rule.RuleDefinition = req.Rule.RuleDefinition
+				updatePredicate = true
+			case "bug":
+				bugID := bugs.BugID{
+					System: req.Rule.Bug.GetSystem(),
+					ID:     req.Rule.Bug.GetId(),
+				}
+				if err := validateBugAgainstConfig(cfg, bugID); err != nil {
+					return invalidArgumentError(err)
+				}
+
+				updatingBug = true // Triggers validation.
+				rule.BugID = bugID
+			case "is_active":
+				rule.IsActive = req.Rule.IsActive
+				updatePredicate = true
+			case "is_managing_bug":
+				updatingManaged = true // Triggers validation.
+				rule.IsManagingBug = req.Rule.IsManagingBug
+			default:
+				return invalidArgumentError(fmt.Errorf("unsupported field mask: %s", path))
+			}
+		}
+
+		if updatingBug || updatingManaged {
+			// Verify the new bug is not used by another rule in the
+			// same project, and that there are not multiple rules
+			// managing the same bug.
+			bugRules, err := rules.ReadByBug(ctx, rule.BugID)
+			if err != nil {
+				// This will result in an internal error being reported
+				// to the caller.
+				return err
+			}
+			for _, otherRule := range bugRules {
+				if otherRule.Project == project && otherRule.RuleID != ruleID {
+					return invalidArgumentError(fmt.Errorf("bug already used by a rule in the same project (%s/%s)", otherRule.Project, otherRule.RuleID))
+				}
+			}
+			for _, otherRule := range bugRules {
+				if otherRule.Project != project && otherRule.IsManagingBug {
+					if updatingManaged && rule.IsManagingBug {
+						// The caller explicitly requested an update of
+						// IsManagingBug to true, but we cannot do this.
+						return invalidArgumentError(fmt.Errorf("bug already managed by a rule in another project (%s/%s)", otherRule.Project, otherRule.RuleID))
+					}
+					// If only changing the bug, avoid conflicts by silently
+					// making the bug not managed by this rule if there is
+					// another rule managing it.
+					// Note: this validation implicitly discloses the existence
+					// of rules in projects other than those the user may have
+					// access to.
+					rule.IsManagingBug = false
+				}
+			}
+		}
+
+		if err := rules.Update(ctx, rule, updatePredicate, user); err != nil {
+			return invalidArgumentError(err)
+		}
+		updatedRule = rule
+		predicateUpdated = updatePredicate
+		return nil
+	}
+	commitTime, err := span.ReadWriteTransaction(ctx, f)
+	if err != nil {
+		return nil, err
+	}
+	updatedRule.LastUpdated = commitTime.In(time.UTC)
+	updatedRule.LastUpdatedUser = user
+	if predicateUpdated {
+		updatedRule.PredicateLastUpdated = commitTime.In(time.UTC)
+	}
+	// Log rule changes to provide a way of recovering old system state
+	// if malicious or unintended updates occur.
+	logRuleUpdate(ctx, originalRule, updatedRule)
+
+	canSeeDefinition := true
+	return createRulePB(updatedRule, cfg.Config, canSeeDefinition), nil
+}
+
+func logRuleUpdate(ctx context.Context, old *rules.FailureAssociationRule, new *rules.FailureAssociationRule) {
+	logging.Infof(ctx, "Rule updated (%s/%s): from %s to %s", old.Project, old.RuleID, formatRule(old), formatRule(new))
+}
+
+func formatRule(r *rules.FailureAssociationRule) string {
+	return fmt.Sprintf("{\n"+
+		"\tRuleDefinition: %q,\n"+
+		"\tBugID: %q,\n"+
+		"\tIsActive: %v,\n"+
+		"\tIsManagingBug: %v,\n"+
+		"\tSourceCluster: %q\n"+
+		"\tLastUpdated: %q\n"+
+		"}", r.RuleDefinition, r.BugID, r.IsActive, r.IsManagingBug, r.SourceCluster, r.LastUpdated.Format(time.RFC3339Nano))
+}
+
+// LookupBug looks up the rule associated with the given bug.
+func (*rulesServer) LookupBug(ctx context.Context, req *pb.LookupBugRequest) (*pb.LookupBugResponse, error) {
+	bug := bugs.BugID{
+		System: req.System,
+		ID:     req.Id,
+	}
+	if err := bug.Validate(); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+	rules, err := rules.ReadByBug(span.Single(ctx), bug)
+	if err != nil {
+		// This will result in an internal error being reported to the caller.
+		return nil, errors.Annotate(err, "reading rule by bug %s:%s", bug.System, bug.ID).Err()
+	}
+	ruleNames := make([]string, 0, len(rules))
+	for _, rule := range rules {
+		allowed, err := perms.HasProjectPermission(ctx, rule.Project, perms.PermListRules)
+		if err != nil {
+			return nil, err
+		}
+		if allowed {
+			ruleNames = append(ruleNames, ruleName(rule.Project, rule.RuleID))
+		}
+	}
+	return &pb.LookupBugResponse{
+		Rules: ruleNames,
+	}, nil
+}
+
+func createRulePB(r *rules.FailureAssociationRule, cfg *configpb.ProjectConfig, includeDefinition bool) *pb.Rule {
+	definition := ""
+	if includeDefinition {
+		definition = r.RuleDefinition
+	}
+	return &pb.Rule{
+		Name:           ruleName(r.Project, r.RuleID),
+		Project:        r.Project,
+		RuleId:         r.RuleID,
+		RuleDefinition: definition,
+		Bug:            createAssociatedBugPB(r.BugID, cfg),
+		IsActive:       r.IsActive,
+		IsManagingBug:  r.IsManagingBug,
+		SourceCluster: &pb.ClusterId{
+			Algorithm: r.SourceCluster.Algorithm,
+			Id:        r.SourceCluster.ID,
+		},
+		CreateTime:              timestamppb.New(r.CreationTime),
+		CreateUser:              r.CreationUser,
+		LastUpdateTime:          timestamppb.New(r.LastUpdated),
+		LastUpdateUser:          r.LastUpdatedUser,
+		PredicateLastUpdateTime: timestamppb.New(r.PredicateLastUpdated),
+		Etag:                    ruleETag(r, includeDefinition),
+	}
+}
+
+func ruleETag(rule *rules.FailureAssociationRule, includeDefinition bool) string {
+	filtered := "y"
+	if includeDefinition {
+		filtered = "n"
+	}
+	return fmt.Sprintf(`W/"%s%s"`, filtered, rule.LastUpdated.UTC().Format(time.RFC3339Nano))
+}
+
+// validateBugAgainstConfig validates the specified bug is consistent with
+// the project configuration.
+func validateBugAgainstConfig(cfg *compiledcfg.ProjectConfig, bug bugs.BugID) error {
+	switch bug.System {
+	case bugs.MonorailSystem:
+		project, _, err := bug.MonorailProjectAndID()
+		if err != nil {
+			return err
+		}
+		if project != cfg.Config.Monorail.Project {
+			return fmt.Errorf("bug not in expected monorail project (%s)", cfg.Config.Monorail.Project)
+		}
+	case bugs.BuganizerSystem:
+		// Buganizer bugs are permitted for all Weetbix projects.
+	default:
+		return fmt.Errorf("unsupported bug system: %s", bug.System)
+	}
+	return nil
+}
diff --git a/analysis/rpc/rules_test.go b/analysis/rpc/rules_test.go
new file mode 100644
index 0000000..31900ac
--- /dev/null
+++ b/analysis/rpc/rules_test.go
@@ -0,0 +1,822 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/secrets"
+	"go.chromium.org/luci/server/secrets/testsecrets"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/protobuf/types/known/fieldmaskpb"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/bugs"
+	"go.chromium.org/luci/analysis/internal/clustering"
+	"go.chromium.org/luci/analysis/internal/clustering/algorithms/testname"
+	"go.chromium.org/luci/analysis/internal/clustering/rules"
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/perms"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	configpb "go.chromium.org/luci/analysis/proto/config"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestRules(t *testing.T) {
+	Convey("With Server", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		// For user identification.
+		ctx = authtest.MockAuthConfig(ctx)
+		authState := &authtest.FakeState{
+			Identity:       "user:someone@example.com",
+			IdentityGroups: []string{"weetbix-access"},
+		}
+		ctx = auth.WithState(ctx, authState)
+		ctx = secrets.Use(ctx, &testsecrets.Store{})
+
+		// Provides datastore implementation needed for project config.
+		ctx = memory.Use(ctx)
+
+		srv := NewRulesSever()
+
+		ruleManagedBuilder := rules.NewRule(0).
+			WithProject(testProject).
+			WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/111"})
+		ruleManaged := ruleManagedBuilder.Build()
+		ruleTwoProject := rules.NewRule(1).
+			WithProject(testProject).
+			WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/222"}).
+			WithBugManaged(false).
+			Build()
+		ruleTwoProjectOther := rules.NewRule(2).
+			WithProject("otherproject").
+			WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/222"}).
+			Build()
+		ruleUnmanagedOther := rules.NewRule(3).
+			WithProject("otherproject").
+			WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/444"}).
+			WithBugManaged(false).
+			Build()
+		ruleManagedOther := rules.NewRule(4).
+			WithProject("otherproject").
+			WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/555"}).
+			WithBugManaged(true).
+			Build()
+		ruleBuganizer := rules.NewRule(5).
+			WithProject(testProject).
+			WithBug(bugs.BugID{System: "buganizer", ID: "666"}).
+			Build()
+
+		err := rules.SetRulesForTesting(ctx, []*rules.FailureAssociationRule{
+			ruleManaged,
+			ruleTwoProject,
+			ruleTwoProjectOther,
+			ruleUnmanagedOther,
+			ruleManagedOther,
+			ruleBuganizer,
+		})
+		So(err, ShouldBeNil)
+
+		cfg := &configpb.ProjectConfig{
+			Monorail: &configpb.MonorailProject{
+				Project:          "monorailproject",
+				DisplayPrefix:    "mybug.com",
+				MonorailHostname: "monorailhost.com",
+			},
+		}
+		err = config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{
+			"testproject": cfg,
+		})
+		So(err, ShouldBeNil)
+
+		Convey("Unauthorised requests are rejected", func() {
+			// Ensure no access to weetbix-access.
+			ctx = auth.WithState(ctx, &authtest.FakeState{
+				Identity: "user:someone@example.com",
+				// Not a member of weetbix-access.
+				IdentityGroups: []string{"other-group"},
+			})
+
+			// Make some request (the request should not matter, as
+			// a common decorator is used for all requests.)
+			request := &pb.GetRuleRequest{
+				Name: fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleManaged.RuleID),
+			}
+
+			rule, err := srv.Get(ctx, request)
+			So(err, ShouldBeRPCPermissionDenied, "not a member of weetbix-access")
+			So(rule, ShouldBeNil)
+		})
+		Convey("Get", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetRule,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetRuleDefinition,
+				},
+			}
+
+			Convey("No get rule permission", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRule)
+
+				request := &pb.GetRuleRequest{
+					Name: fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleManaged.RuleID),
+				}
+
+				rule, err := srv.Get(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.rules.get")
+				So(rule, ShouldBeNil)
+			})
+			Convey("Rule exists", func() {
+				Convey("Read rule with Monorail bug", func() {
+					expectedRule := &pb.Rule{
+						Name:           fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleManaged.RuleID),
+						Project:        ruleManaged.Project,
+						RuleId:         ruleManaged.RuleID,
+						RuleDefinition: ruleManaged.RuleDefinition,
+						Bug: &pb.AssociatedBug{
+							System:   "monorail",
+							Id:       "monorailproject/111",
+							LinkText: "mybug.com/111",
+							Url:      "https://monorailhost.com/p/monorailproject/issues/detail?id=111",
+						},
+						IsActive:      true,
+						IsManagingBug: true,
+						SourceCluster: &pb.ClusterId{
+							Algorithm: ruleManaged.SourceCluster.Algorithm,
+							Id:        ruleManaged.SourceCluster.ID,
+						},
+						CreateTime:              timestamppb.New(ruleManaged.CreationTime),
+						CreateUser:              ruleManaged.CreationUser,
+						LastUpdateTime:          timestamppb.New(ruleManaged.LastUpdated),
+						LastUpdateUser:          ruleManaged.LastUpdatedUser,
+						PredicateLastUpdateTime: timestamppb.New(ruleManaged.PredicateLastUpdated),
+					}
+
+					Convey("With get rule definition permission", func() {
+						request := &pb.GetRuleRequest{
+							Name: fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleManaged.RuleID),
+						}
+
+						rule, err := srv.Get(ctx, request)
+						So(err, ShouldBeNil)
+						includeDefinition := true
+						So(rule, ShouldResembleProto, createRulePB(ruleManaged, cfg, includeDefinition))
+
+						// Also verify createRulePB works as expected, so we do not need
+						// to test that again in later tests.
+						expectedRule.Etag = ruleETag(ruleManaged, includeDefinition)
+						So(rule, ShouldResembleProto, expectedRule)
+					})
+					Convey("Without get rule definition permission", func() {
+						authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRuleDefinition)
+
+						request := &pb.GetRuleRequest{
+							Name: fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleManaged.RuleID),
+						}
+
+						rule, err := srv.Get(ctx, request)
+						So(err, ShouldBeNil)
+						includeDefinition := false
+						So(rule, ShouldResembleProto, createRulePB(ruleManaged, cfg, includeDefinition))
+
+						// Also verify createRulePB works as expected, so we do not need
+						// to test that again in later tests.
+						expectedRule.RuleDefinition = ""
+						expectedRule.Etag = ruleETag(ruleManaged, includeDefinition)
+						So(rule, ShouldResembleProto, expectedRule)
+					})
+				})
+				Convey("Read rule with Buganizer bug", func() {
+					request := &pb.GetRuleRequest{
+						Name: fmt.Sprintf("projects/%s/rules/%s", ruleBuganizer.Project, ruleBuganizer.RuleID),
+					}
+
+					rule, err := srv.Get(ctx, request)
+					So(err, ShouldBeNil)
+					includeDefinition := true
+					So(rule, ShouldResembleProto, createRulePB(ruleBuganizer, cfg, includeDefinition))
+
+					// Also verify createRulePB works as expected, so we do not need
+					// to test that again in later tests.
+					So(rule, ShouldResembleProto, &pb.Rule{
+						Name:           fmt.Sprintf("projects/%s/rules/%s", ruleBuganizer.Project, ruleBuganizer.RuleID),
+						Project:        ruleBuganizer.Project,
+						RuleId:         ruleBuganizer.RuleID,
+						RuleDefinition: ruleBuganizer.RuleDefinition,
+						Bug: &pb.AssociatedBug{
+							System:   "buganizer",
+							Id:       "666",
+							LinkText: "b/666",
+							Url:      "https://issuetracker.google.com/issues/666",
+						},
+						IsActive:      true,
+						IsManagingBug: true,
+						SourceCluster: &pb.ClusterId{
+							Algorithm: ruleBuganizer.SourceCluster.Algorithm,
+							Id:        ruleBuganizer.SourceCluster.ID,
+						},
+						CreateTime:              timestamppb.New(ruleBuganizer.CreationTime),
+						CreateUser:              ruleBuganizer.CreationUser,
+						LastUpdateTime:          timestamppb.New(ruleBuganizer.LastUpdated),
+						LastUpdateUser:          ruleBuganizer.LastUpdatedUser,
+						PredicateLastUpdateTime: timestamppb.New(ruleBuganizer.PredicateLastUpdated),
+						Etag:                    ruleETag(ruleBuganizer, includeDefinition),
+					})
+				})
+			})
+			Convey("Rule does not exist", func() {
+				ruleID := strings.Repeat("00", 16)
+				request := &pb.GetRuleRequest{
+					Name: fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleID),
+				}
+
+				rule, err := srv.Get(ctx, request)
+				So(rule, ShouldBeNil)
+				So(err, ShouldBeRPCNotFound)
+			})
+		})
+		Convey("List", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermListRules,
+				},
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermGetRuleDefinition,
+				},
+			}
+
+			request := &pb.ListRulesRequest{
+				Parent: fmt.Sprintf("projects/%s", testProject),
+			}
+			Convey("No list rules permission", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermListRules)
+
+				response, err := srv.List(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.rules.list")
+				So(response, ShouldBeNil)
+			})
+			Convey("Non-Empty", func() {
+				test := func(includeDefinition bool) {
+					rs := []*rules.FailureAssociationRule{
+						ruleManaged,
+						ruleBuganizer,
+						rules.NewRule(2).WithProject(testProject).Build(),
+						rules.NewRule(3).WithProject(testProject).Build(),
+						rules.NewRule(4).WithProject(testProject).Build(),
+						// In other project.
+						ruleManagedOther,
+					}
+					err := rules.SetRulesForTesting(ctx, rs)
+					So(err, ShouldBeNil)
+
+					response, err := srv.List(ctx, request)
+					So(err, ShouldBeNil)
+
+					expected := &pb.ListRulesResponse{
+						Rules: []*pb.Rule{
+							createRulePB(rs[0], cfg, includeDefinition),
+							createRulePB(rs[1], cfg, includeDefinition),
+							createRulePB(rs[2], cfg, includeDefinition),
+							createRulePB(rs[3], cfg, includeDefinition),
+							createRulePB(rs[4], cfg, includeDefinition),
+						},
+					}
+					sort.Slice(expected.Rules, func(i, j int) bool {
+						return expected.Rules[i].RuleId < expected.Rules[j].RuleId
+					})
+					sort.Slice(response.Rules, func(i, j int) bool {
+						return response.Rules[i].RuleId < response.Rules[j].RuleId
+					})
+					So(response, ShouldResembleProto, expected)
+				}
+				Convey("With get rule definition permission", func() {
+					includeDefinition := true
+					test(includeDefinition)
+				})
+				Convey("Without get rule definition permission", func() {
+					authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermGetRuleDefinition)
+
+					includeDefinition := false
+					test(includeDefinition)
+				})
+			})
+			Convey("Empty", func() {
+				err := rules.SetRulesForTesting(ctx, nil)
+				So(err, ShouldBeNil)
+
+				response, err := srv.List(ctx, request)
+				So(err, ShouldBeNil)
+
+				expected := &pb.ListRulesResponse{}
+				So(response, ShouldResembleProto, expected)
+			})
+			Convey("With project not configured", func() {
+				err = config.SetTestProjectConfig(ctx, map[string]*configpb.ProjectConfig{})
+				So(err, ShouldBeNil)
+
+				// Run
+				response, err := srv.List(ctx, request)
+
+				// Verify
+				So(err, ShouldBeRPCFailedPrecondition, "project does not exist in Weetbix")
+				So(response, ShouldBeNil)
+			})
+		})
+		Convey("Update", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermUpdateRule,
+				},
+			}
+			request := &pb.UpdateRuleRequest{
+				Rule: &pb.Rule{
+					Name:           fmt.Sprintf("projects/%s/rules/%s", ruleManaged.Project, ruleManaged.RuleID),
+					RuleDefinition: `test = "updated"`,
+					Bug: &pb.AssociatedBug{
+						System: "monorail",
+						Id:     "monorailproject/2",
+					},
+					IsManagingBug: false,
+					IsActive:      false,
+				},
+				UpdateMask: &fieldmaskpb.FieldMask{
+					// On the client side, we use JSON equivalents, i.e. ruleDefinition,
+					// bug, isActive, isManagingBug.
+					Paths: []string{"rule_definition", "bug", "is_active", "is_managing_bug"},
+				},
+				Etag: ruleETag(ruleManaged, true /*includeDefinition*/),
+			}
+
+			Convey("No update rules permission", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermUpdateRule)
+
+				rule, err := srv.Update(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.rules.update")
+				So(rule, ShouldBeNil)
+			})
+			Convey("Success", func() {
+				Convey("Predicate updated", func() {
+					rule, err := srv.Update(ctx, request)
+					So(err, ShouldBeNil)
+
+					storedRule, err := rules.Read(span.Single(ctx), testProject, ruleManaged.RuleID)
+					So(err, ShouldBeNil)
+
+					So(storedRule.LastUpdated, ShouldNotEqual, ruleManaged.LastUpdated)
+
+					expectedRule := ruleManagedBuilder.
+						WithRuleDefinition(`test = "updated"`).
+						WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/2"}).
+						WithActive(false).
+						WithBugManaged(false).
+						// Accept whatever the new last updated time is.
+						WithLastUpdated(storedRule.LastUpdated).
+						WithLastUpdatedUser("someone@example.com").
+						// The predicate last updated time should be the same as
+						// the last updated time.
+						WithPredicateLastUpdated(storedRule.LastUpdated).
+						Build()
+
+					// Verify the rule was correctly updated in the database.
+					So(storedRule, ShouldResemble, expectedRule)
+
+					// Verify the returned rule matches what was expected.
+					So(rule, ShouldResembleProto, createRulePB(expectedRule, cfg, true /*includeDefinition*/))
+				})
+				Convey("Predicate not updated", func() {
+					request.UpdateMask.Paths = []string{"bug"}
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: "buganizer",
+						Id:     "99999999",
+					}
+
+					rule, err := srv.Update(ctx, request)
+					So(err, ShouldBeNil)
+
+					storedRule, err := rules.Read(span.Single(ctx), testProject, ruleManaged.RuleID)
+					So(err, ShouldBeNil)
+
+					// Check the rule was updated, but that predicate last
+					// updated time was NOT updated.
+					So(storedRule.LastUpdated, ShouldNotEqual, ruleManaged.LastUpdated)
+
+					expectedRule := ruleManagedBuilder.
+						WithBug(bugs.BugID{System: "buganizer", ID: "99999999"}).
+						// Accept whatever the new last updated time is.
+						WithLastUpdated(storedRule.LastUpdated).
+						WithLastUpdatedUser("someone@example.com").
+						Build()
+
+					// Verify the rule was correctly updated in the database.
+					So(storedRule, ShouldResemble, expectedRule)
+
+					// Verify the returned rule matches what was expected.
+					So(rule, ShouldResembleProto, createRulePB(expectedRule, cfg, true /*includeDefinition*/))
+				})
+				Convey("Re-use of bug managed by another project", func() {
+					request.UpdateMask.Paths = []string{"bug"}
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: ruleManagedOther.BugID.System,
+						Id:     ruleManagedOther.BugID.ID,
+					}
+
+					rule, err := srv.Update(ctx, request)
+					So(err, ShouldBeNil)
+
+					storedRule, err := rules.Read(span.Single(ctx), testProject, ruleManaged.RuleID)
+					So(err, ShouldBeNil)
+
+					// Check the rule was updated.
+					So(storedRule.LastUpdated, ShouldNotEqual, ruleManaged.LastUpdated)
+
+					expectedRule := ruleManagedBuilder.
+						// Verify the bug was updated, but that IsManagingBug
+						// was silently set to false, because ruleManagedOther
+						// already controls the bug.
+						WithBug(ruleManagedOther.BugID).
+						WithBugManaged(false).
+						// Accept whatever the new last updated time is.
+						WithLastUpdated(storedRule.LastUpdated).
+						WithLastUpdatedUser("someone@example.com").
+						Build()
+
+					// Verify the rule was correctly updated in the database.
+					So(storedRule, ShouldResemble, expectedRule)
+
+					// Verify the returned rule matches what was expected.
+					So(rule, ShouldResembleProto, createRulePB(expectedRule, cfg, true /*includeDefinition*/))
+				})
+			})
+			Convey("Concurrent Modification", func() {
+				_, err := srv.Update(ctx, request)
+				So(err, ShouldBeNil)
+
+				// Attempt the same modification again without
+				// requerying.
+				rule, err := srv.Update(ctx, request)
+				So(rule, ShouldBeNil)
+				So(err, ShouldBeRPCAborted)
+			})
+			Convey("Rule does not exist", func() {
+				ruleID := strings.Repeat("00", 16)
+				request.Rule.Name = fmt.Sprintf("projects/%s/rules/%s", testProject, ruleID)
+
+				rule, err := srv.Update(ctx, request)
+				So(rule, ShouldBeNil)
+				So(err, ShouldBeRPCNotFound)
+			})
+			Convey("Validation error", func() {
+				Convey("Invalid bug monorail project", func() {
+					request.Rule.Bug.Id = "otherproject/2"
+
+					rule, err := srv.Update(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "bug not in expected monorail project (monorailproject)")
+				})
+				Convey("Re-use of same bug in same project", func() {
+					// Use the same bug as another rule.
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: ruleTwoProject.BugID.System,
+						Id:     ruleTwoProject.BugID.ID,
+					}
+
+					rule, err := srv.Update(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument,
+						fmt.Sprintf("bug already used by a rule in the same project (%s/%s)",
+							ruleTwoProject.Project, ruleTwoProject.RuleID))
+				})
+				Convey("Bug managed by another rule", func() {
+					// Select a bug already managed by another rule.
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: ruleManagedOther.BugID.System,
+						Id:     ruleManagedOther.BugID.ID,
+					}
+					// Request we manage this bug.
+					request.Rule.IsManagingBug = true
+					request.UpdateMask.Paths = []string{"bug", "is_managing_bug"}
+
+					rule, err := srv.Update(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument,
+						fmt.Sprintf("bug already managed by a rule in another project (%s/%s)",
+							ruleManagedOther.Project, ruleManagedOther.RuleID))
+				})
+				Convey("Invalid rule definition", func() {
+					// Use an invalid failure association rule.
+					request.Rule.RuleDefinition = ""
+
+					rule, err := srv.Update(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "rule definition is not valid")
+				})
+			})
+		})
+		Convey("Create", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermCreateRule,
+				},
+			}
+			request := &pb.CreateRuleRequest{
+				Parent: fmt.Sprintf("projects/%s", testProject),
+				Rule: &pb.Rule{
+					RuleDefinition: `test = "create"`,
+					Bug: &pb.AssociatedBug{
+						System: "monorail",
+						Id:     "monorailproject/2",
+					},
+					IsActive:      false,
+					IsManagingBug: true,
+					SourceCluster: &pb.ClusterId{
+						Algorithm: testname.AlgorithmName,
+						Id:        strings.Repeat("aa", 16),
+					},
+				},
+			}
+
+			Convey("No create rule permission", func() {
+				authState.IdentityPermissions = removePermission(authState.IdentityPermissions, perms.PermCreateRule)
+
+				rule, err := srv.Create(ctx, request)
+				So(err, ShouldBeRPCPermissionDenied, "caller does not have permission weetbix.rules.create")
+				So(rule, ShouldBeNil)
+			})
+			Convey("Success", func() {
+				expectedRuleBuilder := rules.NewRule(0).
+					WithProject(testProject).
+					WithRuleDefinition(`test = "create"`).
+					WithActive(false).
+					WithBugManaged(true).
+					WithCreationUser("someone@example.com").
+					WithLastUpdatedUser("someone@example.com").
+					WithSourceCluster(clustering.ClusterID{
+						Algorithm: testname.AlgorithmName,
+						ID:        strings.Repeat("aa", 16),
+					})
+
+				Convey("Bug not managed by another rule", func() {
+					// Re-use the same bug as a rule in another project,
+					// where the other rule is not managing the bug.
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: ruleUnmanagedOther.BugID.System,
+						Id:     ruleUnmanagedOther.BugID.ID,
+					}
+
+					rule, err := srv.Create(ctx, request)
+					So(err, ShouldBeNil)
+
+					storedRule, err := rules.Read(span.Single(ctx), testProject, rule.RuleId)
+					So(err, ShouldBeNil)
+
+					expectedRule := expectedRuleBuilder.
+						// Accept the randomly generated rule ID.
+						WithRuleID(rule.RuleId).
+						WithBug(ruleUnmanagedOther.BugID).
+						// Accept whatever CreationTime was assigned, as it
+						// is determined by Spanner commit time.
+						// Rule spanner data access code tests already validate
+						// this is populated correctly.
+						WithCreationTime(storedRule.CreationTime).
+						WithLastUpdated(storedRule.CreationTime).
+						WithPredicateLastUpdated(storedRule.CreationTime).
+						Build()
+
+					// Verify the rule was correctly created in the database.
+					So(storedRule, ShouldResemble, expectedRuleBuilder.Build())
+
+					// Verify the returned rule matches our expectations.
+					So(rule, ShouldResembleProto, createRulePB(expectedRule, cfg, true /*includeDefinition*/))
+				})
+				Convey("Bug managed by another rule", func() {
+					// Re-use the same bug as a rule in another project,
+					// where that rule is managing the bug.
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: ruleManagedOther.BugID.System,
+						Id:     ruleManagedOther.BugID.ID,
+					}
+
+					rule, err := srv.Create(ctx, request)
+					So(err, ShouldBeNil)
+
+					storedRule, err := rules.Read(span.Single(ctx), testProject, rule.RuleId)
+					So(err, ShouldBeNil)
+
+					expectedRule := expectedRuleBuilder.
+						// Accept the randomly generated rule ID.
+						WithRuleID(rule.RuleId).
+						WithBug(ruleManagedOther.BugID).
+						// Because another rule is managing the bug, this rule
+						// should be silenlty stopped from managing the bug.
+						WithBugManaged(false).
+						// Accept whatever CreationTime was assigned.
+						WithCreationTime(storedRule.CreationTime).
+						WithLastUpdated(storedRule.CreationTime).
+						WithPredicateLastUpdated(storedRule.CreationTime).
+						Build()
+
+					// Verify the rule was correctly created in the database.
+					So(storedRule, ShouldResemble, expectedRuleBuilder.Build())
+
+					// Verify the returned rule matches our expectations.
+					So(rule, ShouldResembleProto, createRulePB(expectedRule, cfg, true /*includeDefinition*/))
+				})
+				Convey("Buganizer", func() {
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: "buganizer",
+						Id:     "1111111111",
+					}
+
+					rule, err := srv.Create(ctx, request)
+					So(err, ShouldBeNil)
+
+					storedRule, err := rules.Read(span.Single(ctx), testProject, rule.RuleId)
+					So(err, ShouldBeNil)
+
+					expectedRule := expectedRuleBuilder.
+						// Accept the randomly generated rule ID.
+						WithRuleID(rule.RuleId).
+						WithBug(bugs.BugID{System: "buganizer", ID: "1111111111"}).
+						// Accept whatever CreationTime was assigned, as it
+						// is determined by Spanner commit time.
+						// Rule spanner data access code tests already validate
+						// this is populated correctly.
+						WithCreationTime(storedRule.CreationTime).
+						WithLastUpdated(storedRule.CreationTime).
+						WithPredicateLastUpdated(storedRule.CreationTime).
+						Build()
+
+					// Verify the rule was correctly created in the database.
+					So(storedRule, ShouldResemble, expectedRuleBuilder.Build())
+
+					// Verify the returned rule matches our expectations.
+					So(rule, ShouldResembleProto, createRulePB(expectedRule, cfg, true /*includeDefinition*/))
+				})
+			})
+			Convey("Validation error", func() {
+				Convey("Invalid bug monorail project", func() {
+					request.Rule.Bug.Id = "otherproject/2"
+
+					rule, err := srv.Create(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument,
+						"bug not in expected monorail project (monorailproject)")
+				})
+				Convey("Re-use of same bug in same project", func() {
+					// Use the same bug as another rule, in the same project.
+					request.Rule.Bug = &pb.AssociatedBug{
+						System: ruleTwoProject.BugID.System,
+						Id:     ruleTwoProject.BugID.ID,
+					}
+
+					rule, err := srv.Create(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument,
+						fmt.Sprintf("bug already used by a rule in the same project (%s/%s)",
+							ruleTwoProject.Project, ruleTwoProject.RuleID))
+				})
+				Convey("Invalid rule definition", func() {
+					// Use an invalid failure association rule.
+					request.Rule.RuleDefinition = ""
+
+					rule, err := srv.Create(ctx, request)
+					So(rule, ShouldBeNil)
+					So(err, ShouldBeRPCInvalidArgument, "rule definition is not valid")
+				})
+			})
+		})
+		Convey("LookupBug", func() {
+			authState.IdentityPermissions = []authtest.RealmPermission{
+				{
+					Realm:      "testproject:@root",
+					Permission: perms.PermListRules,
+				},
+				{
+					Realm:      "otherproject:@root",
+					Permission: perms.PermListRules,
+				},
+			}
+
+			Convey("Exists None", func() {
+				request := &pb.LookupBugRequest{
+					System: "monorail",
+					Id:     "notexists/1",
+				}
+
+				response, err := srv.LookupBug(ctx, request)
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, &pb.LookupBugResponse{
+					Rules: []string{},
+				})
+			})
+			Convey("Exists One", func() {
+				request := &pb.LookupBugRequest{
+					System: ruleManaged.BugID.System,
+					Id:     ruleManaged.BugID.ID,
+				}
+
+				response, err := srv.LookupBug(ctx, request)
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, &pb.LookupBugResponse{
+					Rules: []string{
+						fmt.Sprintf("projects/%s/rules/%s",
+							ruleManaged.Project, ruleManaged.RuleID),
+					},
+				})
+
+				Convey("If no permission in relevant project", func() {
+					authState.IdentityPermissions = nil
+
+					response, err := srv.LookupBug(ctx, request)
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, &pb.LookupBugResponse{
+						Rules: []string{},
+					})
+				})
+			})
+			Convey("Exists Many", func() {
+				request := &pb.LookupBugRequest{
+					System: ruleTwoProject.BugID.System,
+					Id:     ruleTwoProject.BugID.ID,
+				}
+
+				response, err := srv.LookupBug(ctx, request)
+				So(err, ShouldBeNil)
+				So(response, ShouldResembleProto, &pb.LookupBugResponse{
+					Rules: []string{
+						// Rules are returned alphabetically by project.
+						fmt.Sprintf("projects/otherproject/rules/%s", ruleTwoProjectOther.RuleID),
+						fmt.Sprintf("projects/testproject/rules/%s", ruleTwoProject.RuleID),
+					},
+				})
+
+				Convey("If list permission exists in only some projects", func() {
+					authState.IdentityPermissions = []authtest.RealmPermission{
+						{
+							Realm:      "testproject:@root",
+							Permission: perms.PermListRules,
+						},
+					}
+
+					response, err := srv.LookupBug(ctx, request)
+					So(err, ShouldBeNil)
+					So(response, ShouldResembleProto, &pb.LookupBugResponse{
+						Rules: []string{
+							fmt.Sprintf("projects/testproject/rules/%s", ruleTwoProject.RuleID),
+						},
+					})
+				})
+			})
+		})
+	})
+	Convey("formatRule", t, func() {
+		rule := rules.NewRule(0).
+			WithProject(testProject).
+			WithBug(bugs.BugID{System: "monorail", ID: "monorailproject/123456"}).
+			WithRuleDefinition(`test = "create"`).
+			WithActive(false).
+			WithBugManaged(true).
+			WithSourceCluster(clustering.ClusterID{
+				Algorithm: testname.AlgorithmName,
+				ID:        strings.Repeat("aa", 16),
+			}).Build()
+		expectedRule := `{
+	RuleDefinition: "test = \"create\"",
+	BugID: "monorail:monorailproject/123456",
+	IsActive: false,
+	IsManagingBug: true,
+	SourceCluster: "testname-v3:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
+	LastUpdated: "1900-01-02T03:04:07Z"
+}`
+		So(formatRule(rule), ShouldEqual, expectedRule)
+	})
+}
diff --git a/analysis/rpc/test_history.go b/analysis/rpc/test_history.go
new file mode 100644
index 0000000..c690675
--- /dev/null
+++ b/analysis/rpc/test_history.go
@@ -0,0 +1,257 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/resultdb/rdbperms"
+	"go.chromium.org/luci/server/auth/realms"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/pagination"
+	"go.chromium.org/luci/analysis/internal/perms"
+	"go.chromium.org/luci/analysis/internal/testresults"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func init() {
+	rdbperms.PermListTestResults.AddFlags(realms.UsedInQueryRealms)
+	rdbperms.PermListTestExonerations.AddFlags(realms.UsedInQueryRealms)
+}
+
+var pageSizeLimiter = pagination.PageSizeLimiter{
+	Default: 100,
+	Max:     1000,
+}
+
+// testHistoryServer implements pb.TestHistoryServer.
+type testHistoryServer struct {
+}
+
+// NewTestHistoryServer returns a new pb.TestHistoryServer.
+func NewTestHistoryServer() pb.TestHistoryServer {
+	return &pb.DecoratedTestHistory{
+		Service:  &testHistoryServer{},
+		Postlude: gRPCifyAndLogPostlude,
+	}
+}
+
+// Retrieves test verdicts for a given test ID in a given project and in a given
+// range of time.
+func (*testHistoryServer) Query(ctx context.Context, req *pb.QueryTestHistoryRequest) (*pb.QueryTestHistoryResponse, error) {
+	if err := validateQueryTestHistoryRequest(req); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+
+	subRealms, err := perms.QuerySubRealmsNonEmpty(ctx, req.Project, req.Predicate.SubRealm, nil, perms.ListTestResultsAndExonerations...)
+	if err != nil {
+		return nil, err
+	}
+
+	pageSize := int(pageSizeLimiter.Adjust(req.PageSize))
+	opts := testresults.ReadTestHistoryOptions{
+		Project:          req.Project,
+		TestID:           req.TestId,
+		SubRealms:        subRealms,
+		VariantPredicate: req.Predicate.VariantPredicate,
+		SubmittedFilter:  req.Predicate.SubmittedFilter,
+		TimeRange:        req.Predicate.PartitionTimeRange,
+		PageSize:         pageSize,
+		PageToken:        req.PageToken,
+	}
+
+	verdicts, nextPageToken, err := testresults.ReadTestHistory(span.Single(ctx), opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.QueryTestHistoryResponse{
+		Verdicts:      verdicts,
+		NextPageToken: nextPageToken,
+	}, nil
+}
+
+func validateQueryTestHistoryRequest(req *pb.QueryTestHistoryRequest) error {
+	switch {
+	case req.GetProject() == "":
+		return errors.Reason("project missing").Err()
+	case req.GetTestId() == "":
+		return errors.Reason("test_id missing").Err()
+	}
+
+	if err := pbutil.ValidateTestVerdictPredicate(req.GetPredicate()); err != nil {
+		return errors.Annotate(err, "predicate").Err()
+	}
+
+	if err := pagination.ValidatePageSize(req.GetPageSize()); err != nil {
+		return errors.Annotate(err, "page_size").Err()
+	}
+
+	return nil
+}
+
+// Retrieves a summary of test verdicts for a given test ID in a given project
+// and in a given range of times.
+func (*testHistoryServer) QueryStats(ctx context.Context, req *pb.QueryTestHistoryStatsRequest) (*pb.QueryTestHistoryStatsResponse, error) {
+	if err := validateQueryTestHistoryStatsRequest(req); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+
+	subRealms, err := perms.QuerySubRealmsNonEmpty(ctx, req.Project, req.Predicate.SubRealm, nil, perms.ListTestResultsAndExonerations...)
+	if err != nil {
+		return nil, err
+	}
+
+	pageSize := int(pageSizeLimiter.Adjust(req.PageSize))
+	opts := testresults.ReadTestHistoryOptions{
+		Project:          req.Project,
+		TestID:           req.TestId,
+		SubRealms:        subRealms,
+		VariantPredicate: req.Predicate.VariantPredicate,
+		SubmittedFilter:  req.Predicate.SubmittedFilter,
+		TimeRange:        req.Predicate.PartitionTimeRange,
+		PageSize:         pageSize,
+		PageToken:        req.PageToken,
+	}
+
+	groups, nextPageToken, err := testresults.ReadTestHistoryStats(span.Single(ctx), opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.QueryTestHistoryStatsResponse{
+		Groups:        groups,
+		NextPageToken: nextPageToken,
+	}, nil
+}
+
+func validateQueryTestHistoryStatsRequest(req *pb.QueryTestHistoryStatsRequest) error {
+	switch {
+	case req.GetProject() == "":
+		return errors.Reason("project missing").Err()
+	case req.GetTestId() == "":
+		return errors.Reason("test_id missing").Err()
+	}
+
+	if err := pbutil.ValidateTestVerdictPredicate(req.GetPredicate()); err != nil {
+		return errors.Annotate(err, "predicate").Err()
+	}
+
+	if err := pagination.ValidatePageSize(req.GetPageSize()); err != nil {
+		return errors.Annotate(err, "page_size").Err()
+	}
+
+	return nil
+}
+
+// Retrieves variants for a given test ID in a given project that were recorded
+// in the past 90 days.
+func (*testHistoryServer) QueryVariants(ctx context.Context, req *pb.QueryVariantsRequest) (*pb.QueryVariantsResponse, error) {
+	if err := validateQueryVariantsRequest(req); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+
+	subRealms, err := perms.QuerySubRealmsNonEmpty(ctx, req.Project, req.SubRealm, nil, rdbperms.PermListTestResults)
+	if err != nil {
+		return nil, err
+	}
+
+	pageSize := int(pageSizeLimiter.Adjust(req.PageSize))
+	opts := testresults.ReadVariantsOptions{
+		SubRealms:        subRealms,
+		VariantPredicate: req.VariantPredicate,
+		PageSize:         pageSize,
+		PageToken:        req.PageToken,
+	}
+
+	variants, nextPageToken, err := testresults.ReadVariants(span.Single(ctx), req.GetProject(), req.GetTestId(), opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.QueryVariantsResponse{
+		Variants:      variants,
+		NextPageToken: nextPageToken,
+	}, nil
+}
+
+func validateQueryVariantsRequest(req *pb.QueryVariantsRequest) error {
+	switch {
+	case req.GetProject() == "":
+		return errors.Reason("project missing").Err()
+	case req.GetTestId() == "":
+		return errors.Reason("test_id missing").Err()
+	}
+
+	if err := pagination.ValidatePageSize(req.GetPageSize()); err != nil {
+		return errors.Annotate(err, "page_size").Err()
+	}
+
+	if req.GetVariantPredicate() != nil {
+		if err := pbutil.ValidateVariantPredicate(req.GetVariantPredicate()); err != nil {
+			return errors.Annotate(err, "predicate").Err()
+		}
+	}
+
+	return nil
+}
+
+// QueryTests finds all test IDs that contain the given substring in a given
+// project that were recorded in the past 90 days.
+func (*testHistoryServer) QueryTests(ctx context.Context, req *pb.QueryTestsRequest) (*pb.QueryTestsResponse, error) {
+	if err := validateQueryTestsRequest(req); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+
+	subRealms, err := perms.QuerySubRealmsNonEmpty(ctx, req.Project, req.SubRealm, nil, rdbperms.PermListTestResults)
+	if err != nil {
+		return nil, err
+	}
+
+	pageSize := int(pageSizeLimiter.Adjust(req.PageSize))
+	opts := testresults.QueryTestsOptions{
+		SubRealms: subRealms,
+		PageSize:  pageSize,
+		PageToken: req.GetPageToken(),
+	}
+
+	testIDs, nextPageToken, err := testresults.QueryTests(span.Single(ctx), req.Project, req.TestIdSubstring, opts)
+	if err != nil {
+		return nil, err
+	}
+
+	return &pb.QueryTestsResponse{
+		TestIds:       testIDs,
+		NextPageToken: nextPageToken,
+	}, nil
+}
+
+func validateQueryTestsRequest(req *pb.QueryTestsRequest) error {
+	switch {
+	case req.GetProject() == "":
+		return errors.Reason("project missing").Err()
+	case req.GetTestIdSubstring() == "":
+		return errors.Reason("test_id_substring missing").Err()
+	}
+
+	if err := pagination.ValidatePageSize(req.GetPageSize()); err != nil {
+		return errors.Annotate(err, "page_size").Err()
+	}
+
+	return nil
+}
diff --git a/analysis/rpc/test_history_test.go b/analysis/rpc/test_history_test.go
new file mode 100644
index 0000000..73fdf96
--- /dev/null
+++ b/analysis/rpc/test_history_test.go
@@ -0,0 +1,774 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"testing"
+	"time"
+
+	. "github.com/smartystreets/goconvey/convey"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/resultdb/rdbperms"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/span"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/protobuf/types/known/timestamppb"
+
+	"go.chromium.org/luci/analysis/internal/testresults"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestTestHistoryServer(t *testing.T) {
+	Convey("TestHistoryServer", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		ctx = auth.WithState(ctx, &authtest.FakeState{
+			Identity: "user:someone@example.com",
+			IdentityPermissions: []authtest.RealmPermission{
+				{
+					Realm:      "project:realm",
+					Permission: rdbperms.PermListTestResults,
+				},
+				{
+					Realm:      "project:realm",
+					Permission: rdbperms.PermListTestExonerations,
+				},
+				{
+					Realm:      "project:other-realm",
+					Permission: rdbperms.PermListTestResults,
+				},
+				{
+					Realm:      "project:other-realm",
+					Permission: rdbperms.PermListTestExonerations,
+				},
+			},
+		})
+
+		referenceTime := time.Date(2022, time.January, 1, 0, 0, 0, 0, time.UTC)
+		day := 24 * time.Hour
+
+		var1 := pbutil.Variant("key1", "val1", "key2", "val1")
+		var2 := pbutil.Variant("key1", "val2", "key2", "val1")
+		var3 := pbutil.Variant("key1", "val2", "key2", "val2")
+		var4 := pbutil.Variant("key1", "val1", "key2", "val2")
+		var5 := pbutil.Variant("key1", "val3", "key2", "val2")
+
+		_, err := span.ReadWriteTransaction(ctx, func(ctx context.Context) error {
+			insertTR := func(subRealm string, testID string) {
+				span.BufferWrite(ctx, (&testresults.TestRealm{
+					Project:  "project",
+					TestID:   testID,
+					SubRealm: subRealm,
+				}).SaveUnverified())
+			}
+			insertTR("realm", "test_id")
+			insertTR("realm", "test_id1")
+			insertTR("realm", "test_id2")
+			insertTR("other-realm", "test_id3")
+			insertTR("forbidden-realm", "test_id4")
+
+			insertTVR := func(subRealm string, variant *pb.Variant) {
+				span.BufferWrite(ctx, (&testresults.TestVariantRealm{
+					Project:     "project",
+					TestID:      "test_id",
+					SubRealm:    subRealm,
+					Variant:     variant,
+					VariantHash: pbutil.VariantHash(variant),
+				}).SaveUnverified())
+			}
+
+			insertTVR("realm", var1)
+			insertTVR("realm", var2)
+			insertTVR("realm", var3)
+			insertTVR("other-realm", var4)
+			insertTVR("forbidden-realm", var5)
+
+			insertTV := func(partitionTime time.Time, variant *pb.Variant, invId string, hasUnsubmittedChanges bool, subRealm string) {
+				baseTestResult := testresults.NewTestResult().
+					WithProject("project").
+					WithTestID("test_id").
+					WithVariantHash(pbutil.VariantHash(variant)).
+					WithPartitionTime(partitionTime).
+					WithIngestedInvocationID(invId).
+					WithSubRealm(subRealm).
+					WithStatus(pb.TestResultStatus_PASS).
+					WithoutRunDuration()
+				if hasUnsubmittedChanges {
+					baseTestResult = baseTestResult.WithChangelists([]testresults.Changelist{
+						{
+							Host:     "mygerrit",
+							Change:   4321,
+							Patchset: 5,
+						},
+						{
+							Host:     "anothergerrit",
+							Change:   5471,
+							Patchset: 6,
+						},
+					})
+				} else {
+					baseTestResult = baseTestResult.WithChangelists(nil)
+				}
+
+				trs := testresults.NewTestVerdict().
+					WithBaseTestResult(baseTestResult.Build()).
+					WithStatus(pb.TestVerdictStatus_EXPECTED).
+					WithPassedAvgDuration(nil).
+					Build()
+				for _, tr := range trs {
+					span.BufferWrite(ctx, tr.SaveUnverified())
+				}
+			}
+
+			insertTV(referenceTime.Add(-1*day), var1, "inv1", false, "realm")
+			insertTV(referenceTime.Add(-1*day), var1, "inv2", false, "realm")
+			insertTV(referenceTime.Add(-1*day), var2, "inv1", false, "realm")
+
+			insertTV(referenceTime.Add(-2*day), var1, "inv1", false, "realm")
+			insertTV(referenceTime.Add(-2*day), var1, "inv2", true, "realm")
+			insertTV(referenceTime.Add(-2*day), var2, "inv1", true, "realm")
+
+			insertTV(referenceTime.Add(-3*day), var3, "inv1", true, "realm")
+
+			insertTV(referenceTime.Add(-4*day), var4, "inv2", false, "other-realm")
+			insertTV(referenceTime.Add(-5*day), var5, "inv3", false, "forbidden-realm")
+
+			return nil
+		})
+		So(err, ShouldBeNil)
+
+		server := NewTestHistoryServer()
+
+		Convey("Query", func() {
+			req := &pb.QueryTestHistoryRequest{
+				Project: "project",
+				TestId:  "test_id",
+				Predicate: &pb.TestVerdictPredicate{
+					SubRealm: "realm",
+				},
+				PageSize: 5,
+			}
+
+			Convey("unauthorised requests are rejected", func() {
+				testPerm := func(ctx context.Context) {
+					res, err := server.Query(ctx, req)
+					So(err, ShouldErrLike, `caller does not have permission`, `in realm "project:realm"`)
+					So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
+					So(res, ShouldBeNil)
+				}
+
+				// No permission.
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+				})
+				testPerm(ctx)
+
+				// testResults.list only.
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+					IdentityPermissions: []authtest.RealmPermission{
+						{
+							Realm:      "project:realm",
+							Permission: rdbperms.PermListTestResults,
+						},
+						{
+							Realm:      "project:other_realm",
+							Permission: rdbperms.PermListTestExonerations,
+						},
+					},
+				})
+				testPerm(ctx)
+
+				// testExonerations.list only.
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+					IdentityPermissions: []authtest.RealmPermission{
+						{
+							Realm:      "project:other_realm",
+							Permission: rdbperms.PermListTestResults,
+						},
+						{
+							Realm:      "project:realm",
+							Permission: rdbperms.PermListTestExonerations,
+						},
+					},
+				})
+				testPerm(ctx)
+			})
+
+			Convey("invalid requests are rejected", func() {
+				req.PageSize = -1
+				res, err := server.Query(ctx, req)
+				So(err, ShouldNotBeNil)
+				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
+				So(res, ShouldBeNil)
+			})
+
+			Convey("multi-realms", func() {
+				req.Predicate.SubRealm = ""
+				req.Predicate.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key2", "val2"),
+					},
+				}
+				res, err := server.Query(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestHistoryResponse{
+					Verdicts: []*pb.TestVerdict{
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var3),
+							InvocationId:  "inv1",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-3 * day)),
+						},
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var4),
+							InvocationId:  "inv2",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-4 * day)),
+						},
+					},
+				})
+			})
+
+			Convey("e2e", func() {
+				res, err := server.Query(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestHistoryResponse{
+					Verdicts: []*pb.TestVerdict{
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var1),
+							InvocationId:  "inv1",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-1 * day)),
+						},
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var1),
+							InvocationId:  "inv2",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-1 * day)),
+						},
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var2),
+							InvocationId:  "inv1",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-1 * day)),
+						},
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var1),
+							InvocationId:  "inv1",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-2 * day)),
+						},
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var1),
+							InvocationId:  "inv2",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-2 * day)),
+						},
+					},
+					NextPageToken: res.NextPageToken,
+				})
+				So(res.NextPageToken, ShouldNotBeEmpty)
+
+				req.PageToken = res.NextPageToken
+				res, err = server.Query(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestHistoryResponse{
+					Verdicts: []*pb.TestVerdict{
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var2),
+							InvocationId:  "inv1",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-2 * day)),
+						},
+						{
+							TestId:        "test_id",
+							VariantHash:   pbutil.VariantHash(var3),
+							InvocationId:  "inv1",
+							Status:        pb.TestVerdictStatus_EXPECTED,
+							PartitionTime: timestamppb.New(referenceTime.Add(-3 * day)),
+						},
+					},
+				})
+			})
+		})
+
+		Convey("QueryStats", func() {
+			req := &pb.QueryTestHistoryStatsRequest{
+				Project: "project",
+				TestId:  "test_id",
+				Predicate: &pb.TestVerdictPredicate{
+					SubRealm: "realm",
+				},
+				PageSize: 3,
+			}
+
+			Convey("unauthorised requests are rejected", func() {
+				testPerm := func(ctx context.Context) {
+					res, err := server.QueryStats(ctx, req)
+					So(err, ShouldErrLike, `caller does not have permission`, `in realm "project:realm"`)
+					So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
+					So(res, ShouldBeNil)
+				}
+
+				// No permission.
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+				})
+				testPerm(ctx)
+
+				// testResults.list only.
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+					IdentityPermissions: []authtest.RealmPermission{
+						{
+							Realm:      "project:realm",
+							Permission: rdbperms.PermListTestResults,
+						},
+						{
+							Realm:      "project:other_realm",
+							Permission: rdbperms.PermListTestExonerations,
+						},
+					},
+				})
+				testPerm(ctx)
+
+				// testExonerations.list only.
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+					IdentityPermissions: []authtest.RealmPermission{
+						{
+							Realm:      "project:other_realm",
+							Permission: rdbperms.PermListTestResults,
+						},
+						{
+							Realm:      "project:realm",
+							Permission: rdbperms.PermListTestExonerations,
+						},
+					},
+				})
+				testPerm(ctx)
+			})
+
+			Convey("invalid requests are rejected", func() {
+				req.PageSize = -1
+				res, err := server.QueryStats(ctx, req)
+				So(err, ShouldNotBeNil)
+				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
+				So(res, ShouldBeNil)
+			})
+
+			Convey("multi-realms", func() {
+				req.Predicate.SubRealm = ""
+				req.Predicate.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key2", "val2"),
+					},
+				}
+				res, err := server.QueryStats(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestHistoryStatsResponse{
+					Groups: []*pb.QueryTestHistoryStatsResponse_Group{
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-3 * day)),
+							VariantHash:   pbutil.VariantHash(var3),
+							ExpectedCount: 1,
+						},
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-4 * day)),
+							VariantHash:   pbutil.VariantHash(var4),
+							ExpectedCount: 1,
+						},
+					},
+				})
+			})
+
+			Convey("e2e", func() {
+				res, err := server.QueryStats(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestHistoryStatsResponse{
+					Groups: []*pb.QueryTestHistoryStatsResponse_Group{
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-1 * day)),
+							VariantHash:   pbutil.VariantHash(var1),
+							ExpectedCount: 2,
+						},
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-1 * day)),
+							VariantHash:   pbutil.VariantHash(var2),
+							ExpectedCount: 1,
+						},
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-2 * day)),
+							VariantHash:   pbutil.VariantHash(var1),
+							ExpectedCount: 2,
+						},
+					},
+					NextPageToken: res.NextPageToken,
+				})
+				So(res.NextPageToken, ShouldNotBeEmpty)
+
+				req.PageToken = res.NextPageToken
+				res, err = server.QueryStats(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestHistoryStatsResponse{
+					Groups: []*pb.QueryTestHistoryStatsResponse_Group{
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-2 * day)),
+							VariantHash:   pbutil.VariantHash(var2),
+							ExpectedCount: 1,
+						},
+						{
+							PartitionTime: timestamppb.New(referenceTime.Add(-3 * day)),
+							VariantHash:   pbutil.VariantHash(var3),
+							ExpectedCount: 1,
+						},
+					},
+				})
+			})
+		})
+
+		Convey("QueryVariants", func() {
+			req := &pb.QueryVariantsRequest{
+				Project:  "project",
+				TestId:   "test_id",
+				SubRealm: "realm",
+				PageSize: 2,
+			}
+
+			Convey("unauthorised requests are rejected", func() {
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+				})
+				res, err := server.QueryVariants(ctx, req)
+				So(err, ShouldErrLike, `caller does not have permission`, `in realm "project:realm"`)
+				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
+				So(res, ShouldBeNil)
+			})
+
+			Convey("invalid requests are rejected", func() {
+				req.PageSize = -1
+				res, err := server.QueryVariants(ctx, req)
+				So(err, ShouldNotBeNil)
+				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
+				So(res, ShouldBeNil)
+			})
+
+			Convey("multi-realms", func() {
+				req.PageSize = 0
+				req.SubRealm = ""
+				req.VariantPredicate = &pb.VariantPredicate{
+					Predicate: &pb.VariantPredicate_Contains{
+						Contains: pbutil.Variant("key2", "val2"),
+					},
+				}
+				res, err := server.QueryVariants(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryVariantsResponse{
+					Variants: []*pb.QueryVariantsResponse_VariantInfo{
+						{
+							VariantHash: pbutil.VariantHash(var3),
+							Variant:     var3,
+						},
+						{
+							VariantHash: pbutil.VariantHash(var4),
+							Variant:     var4,
+						},
+					},
+				})
+			})
+
+			Convey("e2e", func() {
+				res, err := server.QueryVariants(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryVariantsResponse{
+					Variants: []*pb.QueryVariantsResponse_VariantInfo{
+						{
+							VariantHash: pbutil.VariantHash(var1),
+							Variant:     var1,
+						},
+						{
+							VariantHash: pbutil.VariantHash(var3),
+							Variant:     var3,
+						},
+					},
+					NextPageToken: res.NextPageToken,
+				})
+				So(res.NextPageToken, ShouldNotBeEmpty)
+
+				req.PageToken = res.NextPageToken
+				res, err = server.QueryVariants(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryVariantsResponse{
+					Variants: []*pb.QueryVariantsResponse_VariantInfo{
+						{
+							VariantHash: pbutil.VariantHash(var2),
+							Variant:     var2,
+						},
+					},
+				})
+			})
+		})
+
+		Convey("QueryTests", func() {
+			req := &pb.QueryTestsRequest{
+				Project:         "project",
+				TestIdSubstring: "test_id",
+				SubRealm:        "realm",
+				PageSize:        2,
+			}
+
+			Convey("unauthorised requests are rejected", func() {
+				ctx = auth.WithState(ctx, &authtest.FakeState{
+					Identity: "user:someone@example.com",
+				})
+				res, err := server.QueryTests(ctx, req)
+				So(err, ShouldErrLike, `caller does not have permission`, `in realm "project:realm"`)
+				So(err, ShouldHaveGRPCStatus, codes.PermissionDenied)
+				So(res, ShouldBeNil)
+			})
+
+			Convey("invalid requests are rejected", func() {
+				req.PageSize = -1
+				res, err := server.QueryTests(ctx, req)
+				So(err, ShouldNotBeNil)
+				So(err, ShouldHaveGRPCStatus, codes.InvalidArgument)
+				So(res, ShouldBeNil)
+			})
+
+			Convey("multi-realms", func() {
+				req.PageSize = 0
+				req.SubRealm = ""
+				res, err := server.QueryTests(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestsResponse{
+					TestIds: []string{"test_id", "test_id1", "test_id2", "test_id3"},
+				})
+			})
+
+			Convey("e2e", func() {
+				res, err := server.QueryTests(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestsResponse{
+					TestIds:       []string{"test_id", "test_id1"},
+					NextPageToken: res.NextPageToken,
+				})
+				So(res.NextPageToken, ShouldNotBeEmpty)
+
+				req.PageToken = res.NextPageToken
+				res, err = server.QueryTests(ctx, req)
+				So(err, ShouldBeNil)
+				So(res, ShouldResembleProto, &pb.QueryTestsResponse{
+					TestIds: []string{"test_id2"},
+				})
+			})
+		})
+	})
+}
+
+func TestValidateQueryTestHistoryRequest(t *testing.T) {
+	t.Parallel()
+
+	Convey("validateQueryTestHistoryRequest", t, func() {
+		req := &pb.QueryTestHistoryRequest{
+			Project: "project",
+			TestId:  "test_id",
+			Predicate: &pb.TestVerdictPredicate{
+				SubRealm: "realm",
+			},
+			PageSize: 5,
+		}
+
+		Convey("valid", func() {
+			err := validateQueryTestHistoryRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("no project", func() {
+			req.Project = ""
+			err := validateQueryTestHistoryRequest(req)
+			So(err, ShouldErrLike, "project missing")
+		})
+
+		Convey("no test_id", func() {
+			req.TestId = ""
+			err := validateQueryTestHistoryRequest(req)
+			So(err, ShouldErrLike, "test_id missing")
+		})
+
+		Convey("no predicate", func() {
+			req.Predicate = nil
+			err := validateQueryTestHistoryRequest(req)
+			So(err, ShouldErrLike, "predicate", "unspecified")
+		})
+
+		Convey("no page size", func() {
+			req.PageSize = 0
+			err := validateQueryTestHistoryRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("negative page size", func() {
+			req.PageSize = -1
+			err := validateQueryTestHistoryRequest(req)
+			So(err, ShouldErrLike, "page_size", "negative")
+		})
+	})
+}
+
+func TestValidateQueryTestHistoryStatsRequest(t *testing.T) {
+	t.Parallel()
+
+	Convey("validateQueryTestHistoryStatsRequest", t, func() {
+		req := &pb.QueryTestHistoryStatsRequest{
+			Project: "project",
+			TestId:  "test_id",
+			Predicate: &pb.TestVerdictPredicate{
+				SubRealm: "realm",
+			},
+			PageSize: 5,
+		}
+
+		Convey("valid", func() {
+			err := validateQueryTestHistoryStatsRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("no project", func() {
+			req.Project = ""
+			err := validateQueryTestHistoryStatsRequest(req)
+			So(err, ShouldErrLike, "project missing")
+		})
+
+		Convey("no test_id", func() {
+			req.TestId = ""
+			err := validateQueryTestHistoryStatsRequest(req)
+			So(err, ShouldErrLike, "test_id missing")
+		})
+
+		Convey("no predicate", func() {
+			req.Predicate = nil
+			err := validateQueryTestHistoryStatsRequest(req)
+			So(err, ShouldErrLike, "predicate", "unspecified")
+		})
+
+		Convey("no page size", func() {
+			req.PageSize = 0
+			err := validateQueryTestHistoryStatsRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("negative page size", func() {
+			req.PageSize = -1
+			err := validateQueryTestHistoryStatsRequest(req)
+			So(err, ShouldErrLike, "page_size", "negative")
+		})
+	})
+}
+
+func TestValidateQueryVariantsRequest(t *testing.T) {
+	t.Parallel()
+
+	Convey("validateQueryVariantsRequest", t, func() {
+		req := &pb.QueryVariantsRequest{
+			Project:  "project",
+			TestId:   "test_id",
+			PageSize: 5,
+		}
+
+		Convey("valid", func() {
+			err := validateQueryVariantsRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("no project", func() {
+			req.Project = ""
+			err := validateQueryVariantsRequest(req)
+			So(err, ShouldErrLike, "project missing")
+		})
+
+		Convey("no test_id", func() {
+			req.TestId = ""
+			err := validateQueryVariantsRequest(req)
+			So(err, ShouldErrLike, "test_id missing")
+		})
+
+		Convey("no page size", func() {
+			req.PageSize = 0
+			err := validateQueryVariantsRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("negative page size", func() {
+			req.PageSize = -1
+			err := validateQueryVariantsRequest(req)
+			So(err, ShouldErrLike, "page_size", "negative")
+		})
+	})
+}
+
+func TestValidateQueryTestsRequest(t *testing.T) {
+	t.Parallel()
+
+	Convey("validateQueryTestsRequest", t, func() {
+		req := &pb.QueryTestsRequest{
+			Project:         "project",
+			TestIdSubstring: "test_id",
+			PageSize:        5,
+		}
+
+		Convey("valid", func() {
+			err := validateQueryTestsRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("no project", func() {
+			req.Project = ""
+			err := validateQueryTestsRequest(req)
+			So(err, ShouldErrLike, "project missing")
+		})
+
+		Convey("no test_id_substring", func() {
+			req.TestIdSubstring = ""
+			err := validateQueryTestsRequest(req)
+			So(err, ShouldErrLike, "test_id_substring missing")
+		})
+
+		Convey("no page size", func() {
+			req.PageSize = 0
+			err := validateQueryTestsRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("negative page size", func() {
+			req.PageSize = -1
+			err := validateQueryTestsRequest(req)
+			So(err, ShouldErrLike, "page_size", "negative")
+		})
+	})
+}
diff --git a/analysis/rpc/test_variant.go b/analysis/rpc/test_variant.go
new file mode 100644
index 0000000..c37dce8
--- /dev/null
+++ b/analysis/rpc/test_variant.go
@@ -0,0 +1,118 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"context"
+	"regexp"
+
+	"go.chromium.org/luci/common/clock"
+	"go.chromium.org/luci/common/errors"
+	"go.chromium.org/luci/server/span"
+
+	"go.chromium.org/luci/analysis/internal/config"
+	"go.chromium.org/luci/analysis/internal/testresults"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+var variantHashRe = regexp.MustCompile("^[0-9a-f]{16}$")
+
+// testVariantsServer implements pb.TestVariantServer.
+type testVariantsServer struct {
+}
+
+// NewTestVariantsServer returns a new pb.TestVariantServer.
+func NewTestVariantsServer() pb.TestVariantsServer {
+	return &pb.DecoratedTestVariants{
+		Prelude:  checkAllowedPrelude,
+		Service:  &testVariantsServer{},
+		Postlude: gRPCifyAndLogPostlude,
+	}
+}
+
+// QueryFailureRate queries the failure rate of specified test variants,
+// returning signals indicating if the test variant is flaky and/or
+// deterministically failing.
+func (*testVariantsServer) QueryFailureRate(ctx context.Context, req *pb.QueryTestVariantFailureRateRequest) (*pb.QueryTestVariantFailureRateResponse, error) {
+	now := clock.Now(ctx)
+	if err := validateQueryTestVariantFailureRateRequest(req); err != nil {
+		return nil, invalidArgumentError(err)
+	}
+
+	opts := testresults.QueryFailureRateOptions{
+		Project:      req.Project,
+		TestVariants: req.TestVariants,
+		AsAtTime:     now,
+	}
+	ctx, cancel := span.ReadOnlyTransaction(ctx)
+	defer cancel()
+	response, err := testresults.QueryFailureRate(ctx, opts)
+	if err != nil {
+		return nil, err
+	}
+	return response, nil
+}
+
+func validateQueryTestVariantFailureRateRequest(req *pb.QueryTestVariantFailureRateRequest) error {
+	// MaxTestVariants is the maximum number of test variants to be queried in one request.
+	const MaxTestVariants = 100
+
+	if req.Project == "" {
+		return errors.Reason("project missing").Err()
+	}
+	if !config.ProjectRe.MatchString(req.Project) {
+		return errors.Reason("project is invalid, expected %s", config.ProjectRePattern).Err()
+	}
+	if len(req.TestVariants) == 0 {
+		return errors.Reason("test_variants missing").Err()
+	}
+	if len(req.TestVariants) > MaxTestVariants {
+		return errors.Reason("test_variants: no more than %v may be queried at a time", MaxTestVariants).Err()
+	}
+	type testVariant struct {
+		testID      string
+		variantHash string
+	}
+	uniqueTestVariants := make(map[testVariant]struct{})
+	for i, tv := range req.TestVariants {
+		if tv.GetTestId() == "" {
+			return errors.Reason("test_variants[%v]: test_id missing", i).Err()
+		}
+		var variantHash string
+		if tv.VariantHash != "" {
+			if !variantHashRe.MatchString(tv.VariantHash) {
+				return errors.Reason("test_variants[%v]: variant_hash is not valid", i).Err()
+			}
+			variantHash = tv.VariantHash
+		}
+
+		// Variant may be nil as not all tests have variants.
+		if tv.Variant != nil || tv.VariantHash == "" {
+			calculatedHash := pbutil.VariantHash(tv.Variant)
+			if tv.VariantHash != "" && calculatedHash != tv.VariantHash {
+				return errors.Reason("test_variants[%v]: variant and variant_hash mismatch, variant hashed to %s, expected %s", i, calculatedHash, tv.VariantHash).Err()
+			}
+			variantHash = calculatedHash
+		}
+
+		key := testVariant{testID: tv.TestId, variantHash: variantHash}
+		if _, ok := uniqueTestVariants[key]; ok {
+			return errors.Reason("test_variants[%v]: already requested in the same request", i).Err()
+		}
+		uniqueTestVariants[key] = struct{}{}
+	}
+	return nil
+}
diff --git a/analysis/rpc/test_variant_test.go b/analysis/rpc/test_variant_test.go
new file mode 100644
index 0000000..e495906
--- /dev/null
+++ b/analysis/rpc/test_variant_test.go
@@ -0,0 +1,219 @@
+// Copyright 2022 The LUCI Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package rpc
+
+import (
+	"fmt"
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+	"go.chromium.org/luci/common/clock/testclock"
+	. "go.chromium.org/luci/common/testing/assertions"
+	"go.chromium.org/luci/gae/impl/memory"
+	"go.chromium.org/luci/server/auth"
+	"go.chromium.org/luci/server/auth/authtest"
+	"go.chromium.org/luci/server/secrets"
+	"go.chromium.org/luci/server/secrets/testsecrets"
+	"google.golang.org/grpc/codes"
+	grpcStatus "google.golang.org/grpc/status"
+
+	"go.chromium.org/luci/analysis/internal/testresults"
+	"go.chromium.org/luci/analysis/internal/testutil"
+	"go.chromium.org/luci/analysis/pbutil"
+	pb "go.chromium.org/luci/analysis/proto/v1"
+)
+
+func TestTestVariantsServer(t *testing.T) {
+	Convey("Given a projects server", t, func() {
+		ctx := testutil.SpannerTestContext(t)
+
+		// For user identification.
+		ctx = authtest.MockAuthConfig(ctx)
+		ctx = auth.WithState(ctx, &authtest.FakeState{
+			Identity:       "user:someone@example.com",
+			IdentityGroups: []string{"weetbix-access"},
+		})
+		ctx = secrets.Use(ctx, &testsecrets.Store{})
+
+		// Provides datastore implementation needed for project config.
+		ctx = memory.Use(ctx)
+		server := NewTestVariantsServer()
+
+		Convey("Unauthorised requests are rejected", func() {
+			ctx = auth.WithState(ctx, &authtest.FakeState{
+				Identity: "user:someone@example.com",
+				// Not a member of weetbix-access.
+				IdentityGroups: []string{"other-group"},
+			})
+
+			// Make some request (the request should not matter, as
+			// a common decorator is used for all requests.)
+			request := &pb.QueryTestVariantFailureRateRequest{}
+
+			response, err := server.QueryFailureRate(ctx, request)
+			st, _ := grpcStatus.FromError(err)
+			So(st.Code(), ShouldEqual, codes.PermissionDenied)
+			So(st.Message(), ShouldEqual, "not a member of weetbix-access")
+			So(response, ShouldBeNil)
+		})
+		Convey("QueryFailureRate", func() {
+			err := testresults.CreateQueryFailureRateTestData(ctx)
+			So(err, ShouldBeNil)
+
+			Convey("Valid input", func() {
+				project, asAtTime, tvs := testresults.QueryFailureRateSampleRequest()
+				request := &pb.QueryTestVariantFailureRateRequest{
+					Project:      project,
+					TestVariants: tvs,
+				}
+				ctx, _ := testclock.UseTime(ctx, asAtTime)
+
+				response, err := server.QueryFailureRate(ctx, request)
+				st, _ := grpcStatus.FromError(err)
+				So(st.Code(), ShouldEqual, codes.OK)
+
+				expectedResult := testresults.QueryFailureRateSampleResponse()
+				So(response, ShouldResembleProto, expectedResult)
+			})
+			Convey("Query by VariantHash", func() {
+				project, asAtTime, tvs := testresults.QueryFailureRateSampleRequest()
+				for _, tv := range tvs {
+					tv.VariantHash = pbutil.VariantHash(tv.Variant)
+					tv.Variant = nil
+				}
+				request := &pb.QueryTestVariantFailureRateRequest{
+					Project:      project,
+					TestVariants: tvs,
+				}
+				ctx, _ := testclock.UseTime(ctx, asAtTime)
+
+				response, err := server.QueryFailureRate(ctx, request)
+				st, _ := grpcStatus.FromError(err)
+				So(st.Code(), ShouldEqual, codes.OK)
+
+				expectedResult := testresults.QueryFailureRateSampleResponse()
+				for _, tv := range expectedResult.TestVariants {
+					tv.VariantHash = pbutil.VariantHash(tv.Variant)
+					tv.Variant = nil
+				}
+				So(response, ShouldResembleProto, expectedResult)
+			})
+			Convey("Invalid input", func() {
+				// This checks at least one case of invalid input is detected, sufficient to verify
+				// validation is invoked.
+				// Exhaustive checking of request validation is performed in TestValidateQueryRateRequest.
+				request := &pb.QueryTestVariantFailureRateRequest{
+					Project: "",
+					TestVariants: []*pb.TestVariantIdentifier{
+						{
+							TestId: "my_test",
+						},
+					},
+				}
+
+				response, err := server.QueryFailureRate(ctx, request)
+				st, _ := grpcStatus.FromError(err)
+				So(st.Code(), ShouldEqual, codes.InvalidArgument)
+				So(st.Message(), ShouldEqual, `project missing`)
+				So(response, ShouldBeNil)
+			})
+		})
+	})
+}
+
+func TestValidateQueryFailureRateRequest(t *testing.T) {
+	Convey("ValidateQueryFailureRateRequest", t, func() {
+		req := &pb.QueryTestVariantFailureRateRequest{
+			Project: "project",
+			TestVariants: []*pb.TestVariantIdentifier{
+				{
+					TestId: "my_test",
+					// Variant is optional as not all tests have variants.
+				},
+				{
+					TestId:  "my_test2",
+					Variant: pbutil.Variant("key1", "val1", "key2", "val2"),
+				},
+			},
+		}
+
+		Convey("valid", func() {
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldBeNil)
+		})
+
+		Convey("no project", func() {
+			req.Project = ""
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, "project missing")
+		})
+
+		Convey("invalid project", func() {
+			req.Project = ":"
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `project is invalid, expected [a-z0-9\-]{1,40}`)
+		})
+
+		Convey("no test variants", func() {
+			req.TestVariants = nil
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `test_variants missing`)
+		})
+
+		Convey("too many test variants", func() {
+			req.TestVariants = make([]*pb.TestVariantIdentifier, 0, 101)
+			for i := 0; i < 101; i++ {
+				req.TestVariants = append(req.TestVariants, &pb.TestVariantIdentifier{
+					TestId: fmt.Sprintf("test_id%v", i),
+				})
+			}
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `no more than 100 may be queried at a time`)
+		})
+
+		Convey("no test id", func() {
+			req.TestVariants[1].TestId = ""
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `test_variants[1]: test_id missing`)
+		})
+
+		Convey("variant_hash invalid", func() {
+			req.TestVariants[1].VariantHash = "invalid"
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `test_variants[1]: variant_hash is not valid`)
+		})
+
+		Convey("variant_hash mismatch with variant", func() {
+			req.TestVariants[1].VariantHash = "0123456789abcdef"
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `test_variants[1]: variant and variant_hash mismatch`)
+		})
+
+		Convey("duplicate test variants", func() {
+			req.TestVariants = []*pb.TestVariantIdentifier{
+				{
+					TestId:  "my_test",
+					Variant: pbutil.Variant("key1", "val1", "key2", "val2"),
+				},
+				{
+					TestId:  "my_test",
+					Variant: pbutil.Variant("key1", "val1", "key2", "val2"),
+				},
+			}
+			err := validateQueryTestVariantFailureRateRequest(req)
+			So(err, ShouldErrLike, `test_variants[1]: already requested in the same request`)
+		})
+	})
+}
diff --git a/go.mod b/go.mod
index f032b8e..c2559e8 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@
 	cloud.google.com/go/storage v1.22.1
 	contrib.go.opencensus.io/exporter/stackdriver v0.13.12
 	github.com/Microsoft/go-winio v0.5.2
+	github.com/alecthomas/participle/v2 v2.0.0-alpha7
 	github.com/alicebob/miniredis/v2 v2.21.0
 	github.com/bazelbuild/buildtools v0.0.0-20220510163207-df8cabe96863
 	github.com/bazelbuild/remote-apis v0.0.0-20220510175640-3b4b64021035
diff --git a/go.sum b/go.sum
index 90ffdce..4688415 100644
--- a/go.sum
+++ b/go.sum
@@ -99,6 +99,10 @@
 github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
 github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
 github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
+github.com/alecthomas/participle/v2 v2.0.0-alpha7 h1:cK4vjj0VSgb3lN1nuKA5F7dw+1s1pWBe5bx7nNCnN+c=
+github.com/alecthomas/participle/v2 v2.0.0-alpha7/go.mod h1:NumScqsC42o9x+dGj8/YqsIfhrIQjFEOFovxotbBirA=
+github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1 h1:GDQdwm/gAcJcLAKQQZGOJ4knlw+7rfEQQcmwTbt4p5E=
+github.com/alecthomas/repr v0.0.0-20181024024818-d37bc2a10ba1/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
 github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk=
 github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc=
 github.com/alicebob/miniredis/v2 v2.21.0 h1:CdmwIlKUWFBDS+4464GtQiQ0R1vpzOgu4Vnd74rBL7M=